diff --git a/.gitignore b/.gitignore index 848b2cb9..5d020e9d 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/ @@ -63,7 +66,35 @@ 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 +# ...and the frozen temporal frame contract, for the same reason: the P6 stability +# test (Optimum.Tests/temporal-contract-tests.cs) reads it. +!docs/temporal-frame-contract.md +# ...and the Vulkan acceptance checklist and the parity allowlist: ssim.py reads the +# allowlist, and Optimum.Tests/parity-dump-coverage-tests.cs reads both. +!docs/vulkan-acceptance.md +!docs/parity-allowlist.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 +# ...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 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/Makefile b/Makefile index c01d97db..f925ff67 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-overlay 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) @@ -102,7 +108,39 @@ 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/ - @cp sources/shaders/*.fsh sources/shaders/*.vsh $(VANILLA_DIR)/assets/game/shaders/ + @# 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)/ + @# 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 + @# 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. + @for f in sources/shaders/*; do [ -f "$$f" ] || continue; cp -f "$$f" "$(VANILLA_DIR)/assets/game/shaders/$$(basename $$f)" || exit 1; done + @# 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 mkdir -p $(VANILLA_DIR)/assets/game/shaderincludes; for f in sources/shaderincludes/*; do [ -f "$$f" ] || continue; cp -f "$$f" "$(VANILLA_DIR)/assets/game/shaderincludes/$$(basename $$f)" || exit 1; done; fi + @# Both copies above are wildcards, so a file that never arrives means a moved + @# source path or a missing destination directory, not a forgotten list entry - + @# and the symptom is silent, vanilla's shader running in place of Optimum's. + @# TAA fails worst that way: its stages (taa-resolve, taa-debug, taa-skymotion, + @# taa-sharpen), the liquid velocity pass and the includes the motion writers + @# compile against have to arrive together or the resolve reads vectors nobody + @# wrote. Fail the deploy instead. + @for f in sources/shaders/* sources/shaderincludes/*; do [ -f "$$f" ] || continue; d="$(VANILLA_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 @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)..."; \ @@ -115,7 +153,13 @@ 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 sources/shaders/*.fsh sources/shaders/*.vsh $(INSTALL_DIR)/assets/game/shaders/; \ + 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; \ 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.Tests/ShaderCompatibilityScannerTests.cs b/Optimum.Launcher.Tests/ShaderCompatibilityScannerTests.cs index 8fe5b972..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; @@ -117,6 +118,305 @@ public void SavedShaderCompatibilityReportDisablesEffectiveMapPageCache() } } + [Theory] + // Optimum's own temporal stages: an external copy of any of them is not a + // writer that stops emitting, it is a second resolve compiled against an MRT + // layout, history format and reactive convention it cannot know. + [InlineData("assets/mymodshaders/shaders/taa-resolve.fsh")] + [InlineData("assets/mymodshaders/shaders/taa-debug.vsh")] + [InlineData("assets/mymodshaders/shaders/taa-skymotion.fsh")] + [InlineData("assets/mymodshaders/shaders/taa-sharpen.fsh")] + // A stage Optimum has not written yet: the "taa-" prefix rule has to cover + // it, or every future stage ships without a scanner verdict. + [InlineData("assets/mymodshaders/shaders/taa-somethingnew.fsh")] + // The liquid velocity pass and the FSR pair the post-resolve sharpen shares + // its vertex stage and lobe maths with. + [InlineData("assets/mymodshaders/shaders/chunkliquidmotion.vsh")] + [InlineData("assets/mymodshaders/shaders/fsr-rcas.fsh")] + [InlineData("assets/mymodshaders/shaders/fsr-easu.vsh")] + // ShaderRegistry merges every shaderinclude into one dictionary that all the + // motion writers compile against, so any file in that directory can redefine + // a helper they call - not only the vertexwarp include named in the rules. + [InlineData("assets/mymodshaders/shaderincludes/vertexwarp.vsh")] + [InlineData("assets/mymodshaders/shaderincludes/somehelper.vsh")] + // Vanilla's shaderincludes/ also carries .ash files, and ShaderRegistry loads + // every include regardless of extension - so the stage-extension filter must + // not apply here or an external .ash helper is invisible to the scanner. + [InlineData("assets/mymodshaders/shaderincludes/foo.ash")] + [InlineData("assets/mymodshaders/shaderincludes/vertexflagbits.ash")] + public void AnExternalCopyOfAnyTaaShaderDisablesTaa(string entryPath) + { + string dataPath = Path.Combine(_root, "data"); + string archivePath = Path.Combine(dataPath, "Mods", "SomeShaderPack.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + using StreamWriter writer = new(archive.CreateEntry(entryPath).Open()); + writer.Write("void main() { }"); + } + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan( + dataPath, + Path.Combine(_root, "game"), + "test"); + + Assert.Contains("Taa", report.DisabledFeatures); + Assert.Contains( + "external shader owns a motion-vector writer contract", + report.FeatureReasons["Taa"]); + } + + [Theory] + [InlineData("assets/mymodshaders/shaders/gui.fsh")] + // Under shaders/ a non-stage extension is not a shader at all: the relaxed + // extension rule belongs to shaderincludes/ only. + [InlineData("assets/mymodshaders/shaders/readme.ash")] + public void AnUnrelatedExternalShaderLeavesTaaAlone(string entryPath) + { + string dataPath = Path.Combine(_root, "data"); + string archivePath = Path.Combine(dataPath, "Mods", "SomeShaderPack.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(archivePath)!); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + using StreamWriter writer = new(archive.CreateEntry(entryPath).Open()); + writer.Write("void main() { }"); + } + + ShaderCompatibilityReport report = ShaderCompatibilityScanner.Scan( + dataPath, + Path.Combine(_root, "game"), + "test"); + + // The veto is explicit, not a blanket "some mod ships shaders" reaction: + // TAA stays available and only an owned contract turns it off. + 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 901ebe20..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", @@ -33,7 +85,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 = @@ -84,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); @@ -128,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; } @@ -209,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) @@ -233,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)) @@ -259,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); @@ -309,10 +444,85 @@ 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"); + + // "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") || + // 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") || + // 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") || + // 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") || + // Every stage Optimum owns outright: the resolve itself, the debug + // views, the sky-motion pass and the post-resolve sharpen. An + // external copy of any of them is not a writer that emits nothing, + // it is a replacement resolve running against a contract (MRT + // layout, history formats, jitter and reactive semantics) it cannot + // know. The prefix rule covers taa-* files added after this line was + // written, so a new stage cannot ship without a scanner rule. + HasExternalShader(report, "taa-resolve.vsh") || HasExternalShader(report, "taa-resolve.fsh") || + HasExternalShader(report, "taa-debug.vsh") || HasExternalShader(report, "taa-debug.fsh") || + HasExternalShader(report, "taa-skymotion.vsh") || HasExternalShader(report, "taa-skymotion.fsh") || + HasExternalShader(report, "taa-sharpen.vsh") || HasExternalShader(report, "taa-sharpen.fsh") || + HasExternalShaderPrefix(report, "taa-") || + // The sharpen pass is an RCAS variant and shares its vertex stage and + // lobe maths with the FSR1 pair, which is also what render scale + // resolves through: an external copy leaves TAA sharpening either + // doubled with FSR's own tap or gone. + HasExternalShader(report, "fsr-rcas.vsh") || HasExternalShader(report, "fsr-rcas.fsh") || + HasExternalShader(report, "fsr-easu.vsh") || HasExternalShader(report, "fsr-easu.fsh") || + // Not just vertexwarp.vsh: ShaderRegistry merges every shaderinclude + // into one dictionary that all the motion writers compile against, so + // an external file anywhere in that directory can redefine a helper + // the writers call - and unlike a shader, an include has no program + // of its own to point the blame at. + HasExternalShader(report, "vertexwarp.vsh") || + HasExternalShaderInclude(report); + AddFeatureDecision(report, "Taa", externalMotionShader, + "external shader owns a motion-vector writer contract"); + if (report.ScanFailed) { 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); @@ -336,6 +546,65 @@ private static bool HasExternalShader(ShaderCompatibilityReport report, string f string.Equals(Path.GetFileName(path), fileName, StringComparison.OrdinalIgnoreCase)); } + /// + /// True when any external shader file name starts with . + /// Optimum's own stages share the "taa-" prefix, so a stage added later is + /// covered without touching the feature decision. + /// + private static bool HasExternalShaderPrefix(ShaderCompatibilityReport report, string prefix) + { + return report.ShaderOwners.Keys.Any(path => + Path.GetFileName(path).StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// True when any external file lands in the shaderincludes directory. + /// NormalizeShaderPath keeps those under a "shaderincludes/" prefix. + /// + private static bool HasExternalShaderInclude(ShaderCompatibilityReport report) + { + return report.ShaderOwners.Keys.Any(path => + 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"; @@ -375,6 +644,7 @@ private static bool IsOptimumSource(string path, string gameDir) string normalized = path.Replace('\\', '/'); int marker = normalized.IndexOf("assets/game/shaders/", StringComparison.OrdinalIgnoreCase); string shader; + bool isInclude = false; if (marker >= 0) { shader = normalized[marker..]; @@ -392,12 +662,45 @@ 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)..]; + isInclude = true; + } + else if (normalized.StartsWith("shaderincludes/", StringComparison.OrdinalIgnoreCase)) + { + shader = normalized; + isInclude = true; + } + else + { + return null; + } } } + // Optimum: the stage-extension filter is only meaningful for shaders/, + // where a file is a vertex, fragment or geometry stage. ShaderRegistry + // loads every shaderinclude regardless of extension - vanilla ships five + // .ash includes next to the .fsh/.vsh ones - so an external .ash override + // replaces a helper the motion writers compile against just the same. string extension = Path.GetExtension(shader); - if (extension is not ".fsh" and not ".vsh" and not ".gsh") return null; + if (isInclude) + { + // Any real file counts; a directory entry (no extension) does not. + if (extension.Length == 0) return null; + } + else if (extension is not ".fsh" and not ".vsh" and not ".gsh") + { + return null; + } + return shader.ToLowerInvariant(); } @@ -488,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/Optimum.Patcher/ILPatcher.cs b/Optimum.Patcher/ILPatcher.cs index bd98c569..5f797ff9 100644 --- a/Optimum.Patcher/ILPatcher.cs +++ b/Optimum.Patcher/ILPatcher.cs @@ -67,7 +67,9 @@ public static int PatchWithInjection( List? hooks = null, Dictionary>? interfacesToInject = null, bool requireAllTargets = true, - Dictionary>? fieldsToRetype = null) + Dictionary>? fieldsToRetype = null, + List? typesToUnseal = null, + List? methodsToVirtualize = null) { var resolver = new DefaultAssemblyResolver(); resolver.AddSearchDirectory(Path.GetDirectoryName(vanillaPath)!); @@ -237,6 +239,18 @@ public static int PatchWithInjection( } } + // Phase 4: platform substitution attribute surgery. Runs after every body + // transplant and hook: TransplantBody never touches MethodAttributes today, but + // applying the flags last means no earlier phase can drop them. + int unsealedTypes = 0; + int virtualizedMethods = 0; + if (typesToUnseal != null && typesToUnseal.Count > 0) + unsealedTypes = PlatformSubstitution.UnsealTypes(vanillaAsm.MainModule, typesToUnseal); + if (methodsToVirtualize != null && methodsToVirtualize.Count > 0) + virtualizedMethods = PlatformSubstitution.VirtualizeMethods(vanillaAsm.MainModule, methodsToVirtualize).Count; + if (unsealedTypes + virtualizedMethods > 0) + Console.WriteLine($" Platform substitution: {unsealedTypes} types unsealed, {virtualizedMethods} methods virtualized."); + int requiredTargetCount = targets.Count(target => !target.Optional); Console.WriteLine( $"\n Summary: {injectedTypes} types, {injectedMembers} members, " + @@ -284,6 +298,23 @@ public static int PatchWithInjection( return -1; } + if (unsealedTypes + virtualizedMethods > 0) + { + var dispatchErrors = PlatformSubstitution.VerifyVirtualDispatch( + vanillaAsm.MainModule, + typesToUnseal ?? new List(), + methodsToVirtualize ?? new List(), + out int virtualCallSites); + if (dispatchErrors.Count > 0) + { + Console.Error.WriteLine($"\n {dispatchErrors.Count} virtual dispatch error(s), output not written:"); + foreach (var err in dispatchErrors) + Console.Error.WriteLine($" {err}"); + return -1; + } + Console.WriteLine($" Virtual dispatch verifier: ok, {virtualCallSites} callvirt/ldvirtftn sites reach virtualized methods, 0 call/ldftn."); + } + AssemblyWriter.Write(vanillaAsm, outputPath, preserveSymbols); if (preserveSymbols) { diff --git a/Optimum.Patcher/Program.cs b/Optimum.Patcher/Program.cs index c9d12d19..ecf777ec 100644 --- a/Optimum.Patcher/Program.cs +++ b/Optimum.Patcher/Program.cs @@ -63,6 +63,159 @@ // --- Phase 2b: Members to inject into existing types --- var membersToInject = new Dictionary> { + // Vulkan-native plan, Phase 1A: graphics bring-up virtuals VulkanClientPlatform overrides + // (injected with their flags, so they arrive virtual), and the ClientProgram.Start helpers + // that wire a platform and let the OpenGL fallback rebuild one. + ["Vintagestory.Client.NoObf.ClientPlatformAbstract"] = new() + { + "InitializeGraphics", + "ShutdownGraphics", + // Phase 1A step 2: the TAA/FSR members the renderers call without a cast to + // ClientPlatformWindows. Neutral bodies; ClientPlatformWindows overrides them. + "MotionAttachmentIndex", + "OptimumMotionWriteActive", + "TaaTargetsReady", + "TaaResolvedThisFrame", + "TaaHistory", + "BeginMotionWrite", + "EndMotionWrite", + "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", + // 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", + // 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", + // 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", + "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 + // 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", + "RenderOverlayLines", + "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. + "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. + "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. + "UseShaderProgram", + "DisposeShaderProgram", + "BindSampler", + "SetUniform", + "SetUniformArray1", + "SetUniformArray2", + "SetUniformArray3", + "SetUniformArray4", + "SetUniformMatrix", + "SetUniformMatrices", + "SetUniformMatrices4x3", + "BindProgramTexture2D", + "BindProgramTextureCube", + "BindUBO", + "UnbindUBO", + "UpdateUBO", + "DeleteUBO", + // Phase 1A step 4: the frame bracket, window-size notification, thick-line probe + // and the graphics-API fragments of the framebuffer, post-chain and TAA methods. + // Neutral bodies; ClientPlatformWindows overrides them with the GL lines and + // VulkanClientPlatform with the device calls. + "BeginFrame", + "EndFrame", + "ProbeThickLineSupport", + "OnWindowSizeChanged", + "BindCurrentFrameBuffer", + "BindCurrentFrameBufferKeepViewport", + "ClearBoundFrameBuffer", + "ClearFrameBufferPass", + "ApplyTransparentPassBlendState", + "SelectBackDrawBuffer", + "SetBlendEnabled", + "ApplyTransparentMergeBlendState", + "ClearSsaoTarget", + "BeginFinalCompositionDrawBuffers", + "RestoreWorldDrawBuffers", + "EnableMotionDrawBuffers", + "RestorePrimaryDrawBuffers", + "EnableMotionOnlyDrawBuffers", + "ApplyOptimumMotionBlendState", + "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", + // 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", + // World/UI separation: the compose ClientMain.RenderToDefaultFramebuffer and + // ScreenManager.Render call; neutral here, the Vulkan platform overrides it. + "OptimumComposeUiTarget", + }, + ["Vintagestory.Client.ClientProgram"] = new() + { + "ConfigureClientPlatform", + "WireClientPlatform", + "OptimumStartSinglePlayerServer", + }, ["Vintagestory.Client.NoObf.ClientSettings"] = new() { "OptimumEntityShadowCull", @@ -81,6 +234,16 @@ "OptimumDynamicLightCache", "OptimumRenderScale", }, + // TAA P4: the cube-particle motion writer's previous-frame uniforms. + ["Vintagestory.Client.NoObf.SystemRenderParticles"] = new() + { + "SetOptimumMotionUniforms", + }, + // TAA P4: the decal motion writer's previous-frame uniforms. + ["Vintagestory.Client.NoObf.SystemRenderDecals"] = new() + { + "SetOptimumMotionUniforms", + }, ["Vintagestory.Client.NoObf.SystemRenderPlayerEffects"] = new() { "GetOptimumLightRadius", @@ -100,6 +263,9 @@ "PrepareOptimumEntityLights", "BeginOptimumEntityShaderSegment", "EndOptimumEntityShaderSegment", + // TAA review fix: per-renderer motion-window gate. + "optimumMotionWriterTypes", + "OptimumIsMotionWriter", }, ["Vintagestory.Client.NoObf.ClientChunk"] = new() { @@ -134,20 +300,253 @@ "_optimumSingleIndirectBufferId", "_optimumSingleIndirectBufferCapacity", "_optimumSharedIndirectCommands", + // 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", + // 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", + // 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", + // 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). + "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", + // Phase 1A step 4: the GL frame end, thick-line probe and parity readback. + "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", + "MotionAttachmentIndex", + "TaaTargetsReady", + // Phase 1A step 2: the four state members above and below are overrides of + // ClientPlatformAbstract's virtuals now, reading these private fields. + "optimumMotionAttachmentIndex", + "optimumTaaTargetsReady", + "optimumTaaResolvedThisFrame", + "optimumMotionWriteActive", + "optimumTaaDisabled", + "TaaHistory", + "CreateOptimumHistoryTargetGl", + "DisableOptimumTaa", + "optimumTaaShaderReloadPending", + "OptimumRunPendingTaaShaderReload", + "_taaFrameParity", + "_taaHistoryValid", + "taaResolvedColorTexture", + "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", + "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", + // Phase 1A step 4: the GL halves of the motion windows and the FSR target + // selection, overrides of ClientPlatformAbstract's virtuals. + "EnableMotionDrawBuffers", + "RestorePrimaryDrawBuffers", + "EnableMotionOnlyDrawBuffers", + "SelectFsrDrawBuffer", + // 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 P4: the sky / volumetric-cloud motion and reactive pass and the + // reactive constant it stamps. + "RenderOptimumSkyMotion", + "OptimumCloudReactive", + // TAA P5: the post-resolve sharpen pass, its dedicated target slot and + // the shared "is FSR's RCAS going to run this frame" test the pass and + // 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 + // 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. + "optimumParityWorldFrames", + "optimumParityDumpDone", + "OptimumRunParityDump", + "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. + "UseShaderProgram", + "DisposeShaderProgram", + "BindSampler", + "SetUniform", + "SetUniformArray1", + "SetUniformArray2", + "SetUniformArray3", + "SetUniformArray4", + "SetUniformMatrix", + "SetUniformMatrices", + "SetUniformMatrices4x3", + "BindProgramTexture2D", + "BindProgramTextureCube", + "BindUBO", + "UnbindUBO", + "UpdateUBO", + "DeleteUBO", + }, + // 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() { "FsrEasu", "FsrRcas", + "TaaDebug", + "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. + "TaaSkyMotion", + // World/UI separation: the UI compose pass program. + "UiCompose", }, ["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", + // TAA P5 review: the terrain sampler objects' LOD bias, reachable from + // ChunkRenderer so the TAA mip-bias row applies without a shader reload. + // A bound sampler object overrides the atlas texture parameter, so this + // is the only place chunkopaque/chunktopsoil mip selection changes. + "ApplyOptimumTerrainSamplerLodBias", + "ApplyOptimumSamplerLodBias", }, ["Vintagestory.Client.NoObf.SystemRenderOITLayers"] = new() { "optimumOitDisabled", "optimumOitFailureLogged", + // Phase 3b: the two OIT targets by handle, for the native OIT merge. + "OptimumOitRevealTexture", + "OptimumOitAccumTexture", "RestoreVanillaTransparentState", "DisableOptimumOit", }, @@ -176,6 +575,11 @@ "onOptimumEntityShaderCacheChanged", "onOptimumRenderScaleChanged", "onOptimumGodRaysCapChanged", + "onOptimumTaaChanged", + "onOptimumTaaSharpnessChanged", + "onOptimumTaaMipBiasChanged", + "onOptimumAmbientOcclusionChanged", + "onOptimumAmbientOcclusionDebugChanged", #if OPTIMUM_GREEDY_MESH "onOptimumGreedyMeshChanged", "onOptimumGreedySpanChanged", @@ -221,6 +625,12 @@ "edgePoolLocationsScratch", "optimumTextureLodBias", "ApplyOptimumTextureLodBias", + "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 @@ -273,6 +683,24 @@ "RegisterTesselationThread", "GetTesselationWorkerSlot", "ChunkTesselatorManager", + // TAA P1: unjittered projection companion to CurrentProjectionMatrix + // (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() + { + "CurrentProjectionMatrixUnjittered", + "TemporalContext", }, // Load-bearing dependency, wire before ServerSystemSupplyChunks: dispatchClaim's @@ -437,6 +865,35 @@ // 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), + // 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 + // 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), + 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), // ClientMain: single-pass OpenedGuis scan instead of two LINQ calls (vanilla fields only) @@ -456,6 +913,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 @@ -475,12 +941,137 @@ 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), + // 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. new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderPostprocessingEffects", 1), + // 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), + // 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), + // 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.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), + // 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", "DisposeFrameBuffers", 1), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "ClearFrameBuffer", 4), + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "ClearFrameBuffer", 1), + 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", "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), + 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" }), + // 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 + // 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), + // 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), // Issue #75 Tier 1: GPU indirect draw submission (glMultiDrawElementsIndirect) new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMesh", 5, new[] { "Vintagestory.API.Client.MeshRef", "System.Int32[]", "System.Int32[]", "System.Int32", "System.Boolean" }), @@ -532,6 +1123,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", 10), + 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) @@ -663,6 +1259,28 @@ new("Vintagestory.Server.ServerPackets", "GetBulkEntityDebugAttributesPacket", 1), }; +// --- Platform substitution (Vulkan-native plan, Phase 0) --- +// Optimum.Render.Vulkan ships VulkanClientPlatform : ClientPlatformWindows and overrides +// these members. ILPatcher applies both lists after every body transplant and hook, so a +// transplant of the same methods cannot drop the flags, then fails the patch if any body +// still reaches a virtualized method with `call`/`ldftn` (a caller that would bypass the +// override). Entries must be public or protected: a private virtual cannot be overridden +// from another assembly. +var typesToUnseal = new List +{ + "Vintagestory.Client.NoObf.ClientPlatformWindows", +}; + +var methodsToVirtualize = new List +{ + new("Vintagestory.Client.NoObf.ClientPlatformWindows", "SetupDefaultFrameBuffers", 0), + 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( vanillaPath, compiledPath, outputPath, typesToInject, membersToInject, targets, @@ -700,7 +1318,9 @@ TargetGenericArity: 0, InsertBeforeTarget: true), }, - fieldsToRetype: fieldsToRetype); + fieldsToRetype: fieldsToRetype, + typesToUnseal: typesToUnseal, + methodsToVirtualize: methodsToVirtualize); Console.WriteLine($"\nDone."); return total > 0 ? 0 : 1; diff --git a/Optimum.Patcher/mod-patcher.cs b/Optimum.Patcher/mod-patcher.cs index 28cc8566..11ea6771 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), @@ -148,6 +157,21 @@ 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), + // 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), + // 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), @@ -235,6 +259,53 @@ 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), + // 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, + // 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.Patcher/platform-substitution.cs b/Optimum.Patcher/platform-substitution.cs new file mode 100644 index 00000000..af13a960 --- /dev/null +++ b/Optimum.Patcher/platform-substitution.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace Optimum.Patcher; + +/// +/// Attribute surgery for platform substitution: a renderer assembly subclasses a vanilla +/// type (ClientPlatformWindows) and overrides members that vanilla declares sealed or +/// non-virtual. clears TypeAttributes.Sealed, +/// turns a non-virtual method into a new virtual slot, and +/// proves no method body still reaches a virtualized +/// method with a non-virtual call (or ldftn): such a caller would silently +/// bypass the override, which is the one failure mode the CLR never reports. +/// +public static class PlatformSubstitution +{ + private const MethodAttributes VirtualFlags = + MethodAttributes.Virtual | MethodAttributes.NewSlot | MethodAttributes.HideBySig; + + public static int UnsealTypes(ModuleDefinition module, IReadOnlyList typeNames) + { + int unsealed = 0; + foreach (string typeName in typeNames) + { + TypeDefinition type = module.GetType(typeName) + ?? throw new InvalidOperationException($"Type to unseal not found: {typeName}"); + if (type.IsInterface || type.IsValueType) + throw new InvalidOperationException($"Type to unseal is not a class: {typeName}"); + // A static class is abstract|sealed; unsealing it would not make it subclassable. + if (type.IsAbstract && type.IsSealed) + throw new InvalidOperationException($"Type to unseal is a static class: {typeName}"); + type.Attributes &= ~TypeAttributes.Sealed; + unsealed++; + } + return unsealed; + } + + public static List VirtualizeMethods(ModuleDefinition module, IReadOnlyList targets) + { + var virtualized = new List(); + foreach (MethodTarget target in targets) + { + MethodDefinition method = FindTarget(module, target); + string? visibility = OverridableVisibilityError(method); + if (visibility != null) + throw new InvalidOperationException( + $"Method to virtualize is {visibility} and cannot be overridden from another assembly: {target}"); + if (method.IsStatic || method.IsConstructor) + throw new InvalidOperationException($"Method to virtualize is static or a constructor: {target}"); + if (method.IsVirtual && (!method.IsNewSlot || method.IsFinal)) + throw new InvalidOperationException( + $"Method to virtualize already overrides a base slot or is final: {target}"); + + method.Attributes |= VirtualFlags; + virtualized.Add(method); + } + return virtualized; + } + + /// + /// Returns one error per violation: a type that still carries Sealed, a method without + /// Virtual, and every call/ldftn whose operand resolves to a virtualized + /// method. A call from a subclass of the declaring type (a base.X() call) + /// is legitimate and accepted. + /// + public static List VerifyVirtualDispatch( + ModuleDefinition module, + IReadOnlyList unsealedTypes, + IReadOnlyList virtualizedMethods, + out int virtualCallSites) + { + var errors = new List(); + virtualCallSites = 0; + + foreach (string typeName in unsealedTypes) + { + TypeDefinition? type = module.GetType(typeName); + if (type == null) + errors.Add($"unsealed type {typeName} is missing from the output module"); + else if (type.IsSealed) + errors.Add($"unsealed type {typeName} still carries TypeAttributes.Sealed"); + } + + var definitions = new List(); + foreach (MethodTarget target in virtualizedMethods) + { + MethodDefinition method; + try + { + method = FindTarget(module, target); + } + catch (InvalidOperationException error) + { + errors.Add(error.Message); + continue; + } + if (!method.IsVirtual) + errors.Add($"virtualized method {method.FullName} is not virtual"); + definitions.Add(method); + } + if (definitions.Count == 0) + return errors; + + var names = new HashSet(StringComparer.Ordinal); + foreach (MethodDefinition method in definitions) + names.Add(method.Name); + + var typesByName = new Dictionary(StringComparer.Ordinal); + var allTypes = new List(); + foreach (TypeDefinition type in module.Types) + Collect(type, allTypes, typesByName); + + foreach (TypeDefinition type in allTypes) + { + foreach (MethodDefinition caller in type.Methods) + { + if (!caller.HasBody) continue; + foreach (Instruction instruction in caller.Body.Instructions) + { + OpCode opCode = instruction.OpCode; + if (opCode.Code != Code.Call && opCode.Code != Code.Callvirt && + opCode.Code != Code.Ldftn && opCode.Code != Code.Ldvirtftn) + continue; + if (instruction.Operand is not MethodReference reference || !names.Contains(reference.Name)) + continue; + + MethodDefinition? resolved = Match(definitions, reference); + if (resolved == null) continue; + + if (opCode.Code == Code.Callvirt || opCode.Code == Code.Ldvirtftn) + { + virtualCallSites++; + continue; + } + if (opCode.Code == Code.Call && IsStrictSubclass(type, resolved.DeclaringType, typesByName)) + continue; + + errors.Add( + $"{caller.FullName} reaches virtualized {resolved.FullName} with {opCode.Name} " + + $"at IL_{instruction.Offset:X4}; an override would be bypassed"); + } + } + } + + return errors; + } + + private static MethodDefinition FindTarget(ModuleDefinition module, MethodTarget target) + { + TypeDefinition type = module.GetType(target.TypeFullName) + ?? throw new InvalidOperationException($"Type of method to virtualize not found: {target}"); + MethodDefinition? found = null; + foreach (MethodDefinition method in type.Methods) + { + if (method.Name != target.MethodName || method.Parameters.Count != target.ParamCount || !target.Matches(method)) + continue; + if (found != null) + throw new InvalidOperationException($"Ambiguous method to virtualize: {target}"); + found = method; + } + return found ?? throw new InvalidOperationException($"Method to virtualize not found: {target}"); + } + + private static string? OverridableVisibilityError(MethodDefinition method) + { + switch (method.Attributes & MethodAttributes.MemberAccessMask) + { + case MethodAttributes.Public: + case MethodAttributes.Family: + case MethodAttributes.FamORAssem: + return null; + case MethodAttributes.Private: + return "private"; + case MethodAttributes.Assembly: + return "internal"; + case MethodAttributes.FamANDAssem: + return "private protected"; + default: + return "compiler-controlled"; + } + } + + private static MethodDefinition? Match(List definitions, MethodReference reference) + { + foreach (MethodDefinition definition in definitions) + { + if (MethodSignature.Matches(definition, reference)) + return definition; + } + return null; + } + + private static bool IsStrictSubclass( + TypeDefinition type, TypeDefinition baseType, Dictionary typesByName) + { + TypeReference? current = type.BaseType; + int guard = 0; + while (current != null && guard++ < 64) + { + string name = current is GenericInstanceType generic ? generic.ElementType.FullName : current.FullName; + if (name == baseType.FullName) + return true; + if (!typesByName.TryGetValue(name, out TypeDefinition? next)) + return false; + current = next.BaseType; + } + return false; + } + + private static void Collect(TypeDefinition type, List all, Dictionary byName) + { + all.Add(type); + byName[type.FullName] = type; + foreach (TypeDefinition nested in type.NestedTypes) + Collect(nested, all, byName); + } +} diff --git a/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs b/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs new file mode 100644 index 00000000..284872e5 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AllocatorPolicyTests.cs @@ -0,0 +1,447 @@ +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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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) + { + VulkanDevice 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); + ValidationAssert.NoSyncHazards(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) + { + VulkanDevice 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/AllocatorTests.cs b/Optimum.Render.Vulkan.Tests/AllocatorTests.cs new file mode 100644 index 00000000..3ece2c4b --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AllocatorTests.cs @@ -0,0 +1,273 @@ +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) => + GpuTest.TryCreateContext(output, null, out context); + + /// + /// 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + + 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/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/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/AsyncTransferTests.cs b/Optimum.Render.Vulkan.Tests/AsyncTransferTests.cs new file mode 100644 index 00000000..64fe8c48 --- /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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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 new file mode 100644 index 00000000..dc618274 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/AttachmentSemanticsTests.cs @@ -0,0 +1,549 @@ +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; + +/// +/// 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) => + GpuTest.TryCreateContext(output, messages, out context); + + /// + /// 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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]); + + 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); + + ValidationAssert.NoSyncHazards(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. 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 UnwrittenButEnabledAttachmentsKeepTheirContents() + { + 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 PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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]); + + 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]); + + // 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); + 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: {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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + // 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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + 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); + + 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); + + ValidationAssert.NoSyncHazards(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, SetupQueue commands, RenderTargetManager targets, + 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 = targets.FormatsOf(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, PipelineKeyState.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 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. + /// + private static unsafe void RenderFullscreenSamplingDepth( + VulkanContext context, SetupQueue commands, TextureManager textures, RenderTargetManager targets, + GraphicsPipelineCache pipelines, PipelineKeyState state, ShaderProgramResources program, + int framebuffer, int sampledDepthTextureId, uint size) + { + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = targets.FormatsOf(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 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); + + api.CmdBindPipeline(commandBuffer, PipelineBindPoint.Graphics, pipeline); + binding.Bind(commandBuffer, program, samplers); + + 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, PipelineKeyState.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, SetupQueue 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/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/BindlessCapabilityTests.cs b/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs new file mode 100644 index 00000000..9486e911 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/BindlessCapabilityTests.cs @@ -0,0 +1,236 @@ +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: DescriptorIndexingFloor.RequiredDynamicUniformBuffers, + 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); + uint dynamicBelow = DescriptorIndexingFloor.RequiredDynamicUniformBuffers - 1; + AssertSingle(AtFloor() with { MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic = dynamicBelow }, + "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", dynamicBelow, + DescriptorIndexingFloor.RequiredDynamicUniformBuffers.ToString()); + 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/BindlessTextureTableTests.cs b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs new file mode 100644 index 00000000..f5cefbad --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/BindlessTextureTableTests.cs @@ -0,0 +1,767 @@ +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 static readonly byte[] OpaqueBlack = { 0, 0, 0, 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(OpaqueBlack, 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: + /// opaque black, as OpenGL reads an unbound texture, and magenta only under + /// poison mode, where an undefined read is meant to be loud. + /// + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void AWrongKindRequestResolvesToThePlaceholderSlot(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; + 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(poison ? Magenta : OpaqueBlack, harness.Pixel(target)); + 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.Tests/ChunkRenderPathTests.cs b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs new file mode 100644 index 00000000..0ffb0dbb --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ChunkRenderPathTests.cs @@ -0,0 +1,589 @@ +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) => + GpuTest.TryCreateContext(output, messages, out context); + + /// + /// 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!); + 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); + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + 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); + + 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); + } + + // 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); + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + using var pipelines = new GraphicsPipelineCache(context!); + using var meshes = new MeshManager(context!); + 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. + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + 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); + + 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); + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + using var pipelines = new GraphicsPipelineCache(context!); + 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); + + // 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. + // 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) + { + 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 = targets.FormatsOf(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); + + using var binding = new SharedLayoutTestBinding(context!, textures); + VulkanBuffer faceBuffer = meshes.BufferOf(mesh, MeshManager.BufferXyz)!; + BlockBinding storageBlock = Assert.Single(program.Interface.StorageBlocks); + Assert.Equal(SetConvention.FaceDataBinding, storageBlock.Binding); + + 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); + binding.Bind(commandBuffer, program, new Dictionary(), + storage: faceBuffer); + + 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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + using var pipelines = new GraphicsPipelineCache(context!); + 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); + + 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 = targets.FormatsOf(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]); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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, PipelineKeyState.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, SetupQueue 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/ChunkTerrainRenderTests.cs b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs new file mode 100644 index 00000000..6df17d3d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ChunkTerrainRenderTests.cs @@ -0,0 +1,1052 @@ +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 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) => + GpuTest.TryCreateDevice(output, out device); + + 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: UpNormalFlags); + } + + foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) + { + mesh.AddIndex(index); + } + 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) + { + VulkanDevice 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. + /// + [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) + { + VulkanDevice 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) + { + VulkanDevice 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 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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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(VulkanDevice 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; + + private static int LinkFromCorpus( + VulkanDevice 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. + /// + /// The first texture unit the program did not claim. + private static unsafe int BindEveryDeclaredSampler( + VulkanDevice device, VulkanDevice 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++; + } + + return 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(VulkanDevice 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); + 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(VulkanDevice 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(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", "modelMatrix", "mvpMatrix", + "toShadowMapSpaceMatrixFar", "toShadowMapSpaceMatrixNear", + }) + { + int location = seam.GetUniformLocation(programId, name); + if (location >= 0) seam.SetUniformMatrix(programId, location, identity); + } + } + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); +} diff --git a/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs b/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs new file mode 100644 index 00000000..6247e572 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ColorWriteTierTests.cs @@ -0,0 +1,67 @@ +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 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/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/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/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/FragmentOutputAssignmentTests.cs b/Optimum.Render.Vulkan.Tests/FragmentOutputAssignmentTests.cs new file mode 100644 index 00000000..e66688f9 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FragmentOutputAssignmentTests.cs @@ -0,0 +1,104 @@ +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Shaders; +using Vintagestory.API.Client; +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)); + } + + private static ProgramInterfaceLayout LayoutOf(string fragmentSource) + => ProgramInterfaceLayout.Build(new List<(EnumShaderType, ParsedShader)> + { + (EnumShaderType.VertexShader, GlslParser.Parse("#version 330 core\nvoid main(){ }")), + (EnumShaderType.FragmentShader, GlslParser.Parse(fragmentSource)), + }); + + /// + /// An output array with only a constant element stored to marks just that + /// element's location written. Marking the whole span would leave colour + /// writes on for an attachment the shader never touches - GL keeps such an + /// attachment, Vulkan fills it with undefined data. + /// + [Fact] + public void AConstantArrayIndexMarksOnlyThatElement() + { + ProgramInterfaceLayout layout = LayoutOf( + "#version 330 core\nlayout(location = 0) out vec4 motion[2];\n" + + "void main(){ motion[1] = vec4(1.0); }"); + + Assert.Equal(new[] { 1 }, layout.WrittenFragmentOutputs.OrderBy(i => i).ToArray()); + } + + /// Several constant indices each mark their own location, and no others. + [Fact] + public void SeveralConstantArrayIndicesMarkEachElement() + { + ProgramInterfaceLayout layout = LayoutOf( + "#version 330 core\nlayout(location = 0) out vec4 motion[3];\n" + + "void main(){ motion[0] = vec4(1.0); motion[2].rgb = vec3(0.0); }"); + + Assert.Equal(new[] { 0, 2 }, layout.WrittenFragmentOutputs.OrderBy(i => i).ToArray()); + } + + /// + /// A dynamic index could hit any element, so the whole span stays written - + /// masking a written attachment off would be the worse failure. + /// + [Fact] + public void ADynamicArrayIndexKeepsTheWholeSpan() + { + ProgramInterfaceLayout layout = LayoutOf( + "#version 330 core\nlayout(location = 0) out vec4 motion[2];\nuniform int slot;\n" + + "void main(){ motion[slot] = vec4(1.0); }"); + + Assert.Equal(new[] { 0, 1 }, layout.WrittenFragmentOutputs.OrderBy(i => i).ToArray()); + } + + /// A store to the array as a whole writes every element. + [Fact] + public void AWholeArrayStoreKeepsTheWholeSpan() + { + ProgramInterfaceLayout layout = LayoutOf( + "#version 330 core\nlayout(location = 0) out vec4 motion[2];\nuniform vec4 src[2];\n" + + "void main(){ motion = src; }"); + + Assert.Equal(new[] { 0, 1 }, layout.WrittenFragmentOutputs.OrderBy(i => i).ToArray()); + } + + /// + /// Non-array outputs are untouched by the element tracking, including the + /// component-indexed store, where the index selects a channel rather than + /// an attachment. + /// + [Fact] + public void NonArrayOutputsAreUnchanged() + { + ProgramInterfaceLayout layout = LayoutOf( + "#version 330 core\nlayout(location = 0) out vec4 outColor;\n" + + "layout(location = 1) out vec4 outGlow;\nlayout(location = 2) out vec4 outUntouched;\n" + + "void main(){ outColor = vec4(1.0); outGlow[2] = 0.5; }"); + + Assert.Equal(new[] { 0, 1 }, layout.WrittenFragmentOutputs.OrderBy(i => i).ToArray()); + } +} diff --git a/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs b/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs new file mode 100644 index 00000000..3ea024d0 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsDeviceTests.cs @@ -0,0 +1,209 @@ +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); + } + } + + /// + /// 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"); + 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..37ff06b1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameGlobalsTests.cs @@ -0,0 +1,272 @@ +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 = 2, binding = 3) 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"); + } + } + + /// + /// 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)); + 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/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/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.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.Tests/FrameRingTests.cs b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs new file mode 100644 index 00000000..f5a29274 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameRingTests.cs @@ -0,0 +1,378 @@ +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) => + GpuTest.TryCreateContext(output, null, out context); + + 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(); + + // The slot object is reused by later frames, so its value is captured now. + ulong queuedAt = ring.BeginFrame().FrameValue; + ring.DeferDeletion(resource); + ring.EndFrame(); + Assert.False(resource.Disposed, "must not be freed during the frame that queued it"); + + // Keyed on the queuing frame's value: freed only once the Frame + // timeline passed it. Completion is monotonic, so a resource seen + // freed implies the counter is at or past that value now. + for (int frame = 0; frame < 3; frame++) + { + ring.BeginFrame(); + ring.EndFrame(); + Assert.True(!resource.Disposed || ring.Timeline.FrameCompleted >= queuedAt, + "freed before the Frame timeline passed the frame that queued it"); + } + + // Once the timeline demonstrably passed it, the next frame start frees it. + ring.Timeline.WaitForFrame(ring.Timeline.FrameSignalled, WaitSite.DeviceWaitIdle); + ring.BeginFrame(); + ring.EndFrame(); + Assert.True(resource.Disposed, "should be freed once the Frame timeline passed the queuing frame"); + + 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); + } + } + + /// + /// 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, + /// 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 + + [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.StandaloneLayout!.FrameSetLayout; + + 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); + 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.StandaloneLayout!.FrameSetLayout; + + // 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, SetConvention.FrameSet, + new[] { new SamplerBindingValue((uint)SetConvention.FrameTextures[0].Value, 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/FrameTimelinePacingTests.cs b/Optimum.Render.Vulkan.Tests/FrameTimelinePacingTests.cs new file mode 100644 index 00000000..e2afcbdf --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/FrameTimelinePacingTests.cs @@ -0,0 +1,163 @@ +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 1 through the seam: the Frame timeline paces the frame ring and +/// keys every deferred destruction. +/// +public class FrameTimelinePacingTests +{ + private readonly ITestOutputHelper _output; + + public FrameTimelinePacingTests(ITestOutputHelper output) => _output = output; + + /// + /// A hundred presented frames, each of which renders into a texture and a + /// framebuffer it creates, then deletes both (and a mesh) before presenting. + /// The frame's own command buffer still names the texture, so destroying it + /// before the timeline passed that frame is a validation error (image in use + /// by a pending command buffer). Each frame start is exactly one pacing wait, + /// nothing else in the loop waits, and the retire queue drains instead of growing. + /// + [SkippableFact] + public unsafe void HundredFramesWithDeferredDeletesPaceOnceEachAndStayClean() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 4; + const int frames = 100; + + int target = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int targetFramebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(targetFramebuffer, EnumFramebufferAttachment.ColorAttachment0, target, 0); + seam.SetDrawBuffers(targetFramebuffer, 1); + + // Warm-up frame outside the counted window. + seam.BeginFrame(); + seam.Present(); + + long pacingBefore = VulkanStats.WaitCount(WaitSite.FramePacing); + long[] othersBefore = OtherWaits(); + ulong signalledBefore = device!.TimelineForTests.FrameSignalled; + int peakPending = 0; + + for (int frame = 0; frame < frames; frame++) + { + seam.BeginFrame(); + peakPending = Math.Max(peakPending, device.PendingRetirementsForTests); + + int scratch = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int scratchFramebuffer = seam.CreateFramebuffer(size, size); + seam.AttachTexture(scratchFramebuffer, EnumFramebufferAttachment.ColorAttachment0, scratch, 0); + seam.SetDrawBuffers(scratchFramebuffer, 1); + seam.BindFramebuffer(scratchFramebuffer); + seam.ClearColor(0, 1f, 0f, 0f, 1f); + + int mesh = seam.CreateMesh(new MeshData(4, 6) + { + 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, + mode = EnumDrawMode.Triangles, + }, true); + + seam.BindFramebuffer(targetFramebuffer); + // Exact in 8 bits (x.5 rounds either way): 0.2 -> 51, 0.25 -> 64. + seam.ClearColor(0, frame / 255f, 0.2f, 0.25f, 1f); + + seam.DeleteMesh(mesh); + seam.DeleteFramebuffer(scratchFramebuffer); + seam.DeleteTexture(scratch); + + seam.Present(); + } + + long pacingDelta = VulkanStats.WaitCount(WaitSite.FramePacing) - pacingBefore; + long[] othersAfter = OtherWaits(); + ulong signalledDelta = device.TimelineForTests.FrameSignalled - signalledBefore; + _output.WriteLine($"pacing waits {pacingDelta}, frames signalled {signalledDelta}, peak pending {peakPending}"); + + Assert.Equal(frames, pacingDelta); + Assert.Equal((ulong)frames, signalledDelta); + for (int i = 0; i < OtherSites.Length; i++) + { + long delta = othersAfter[i] - othersBefore[i]; + // A frame submit is counted at its own site; it is not a pacing wait. + long expected = OtherSites[i] == WaitSite.QueueSubmit ? frames : 0; + Assert.True(delta == expected, + $"{VulkanStats.WaitSiteTokens[(int)OtherSites[i]]}: {delta} waits in the loop, expected {expected}"); + } + + // Two frames in flight: at a frame start at most the last two frames' + // deletions (texture, mesh, freed descriptor sets) can still be pending. + Assert.True(peakPending <= 3 * 3, $"retire queue grew to {peakPending}"); + + // Once the timeline passed the last frame, the next frame start frees everything. + device.TimelineForTests.WaitForFrame(device.TimelineForTests.FrameSignalled, WaitSite.DeviceWaitIdle); + seam.BeginFrame(); + Assert.Equal(0, device.PendingRetirementsForTests); + + seam.BindFramebuffer(targetFramebuffer); + var pixels = new byte[size * size * 4]; + fixed (byte* destination = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + Assert.Equal(new byte[] { 99, 51, 64, 255 }, pixels[0..4]); + seam.Present(); + + GpuTest.AssertClean(seam); + } + } + + private static readonly WaitSite[] OtherSites = + { + WaitSite.UploadSubmit, WaitSite.FlushFrame, WaitSite.DeviceWaitIdle, WaitSite.Readback, + WaitSite.OcclusionQuery, WaitSite.SwapchainAcquire, WaitSite.Present, WaitSite.QueueSubmit, + }; + + private static long[] OtherWaits() + { + var counts = new long[OtherSites.Length]; + for (int i = 0; i < OtherSites.Length; i++) counts[i] = VulkanStats.WaitCount(OtherSites[i]); + return counts; + } + + /// + /// Frame values are handed out in order, one per submitted frame including + /// mid-frame flushes, and the timeline counter reaches the last one. + /// + [SkippableFact] + public unsafe void EverySubmittedFrameSignalsTheNextFrameValue() + { + Skip.IfNot(GpuTest.TryCreateContext(_output, null, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); + for (ulong frame = 1; frame <= 7; frame++) + { + FrameSlot slot = ring.BeginFrame(); + Assert.Equal(frame, slot.FrameValue); + Assert.Equal(frame, ring.Timeline.FrameRecorded); + ring.EndFrame(); + Assert.Equal(frame, ring.Timeline.FrameSignalled); + } + + ring.Timeline.WaitForFrame(7, WaitSite.DeviceWaitIdle); + Assert.Equal(7UL, ring.Timeline.FrameCompleted); + // Nothing ever signals Transfer yet. + Assert.Equal(0UL, ring.Timeline.TransferCompleted); + + VulkanStats.WaitDeviceIdle(context!.Api, context.Device); + } + } +} 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/GpuCheckpointTests.cs b/Optimum.Render.Vulkan.Tests/GpuCheckpointTests.cs new file mode 100644 index 00000000..04508bf0 --- /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 = GpuTest.ContextOptions(); + 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/GpuTest.cs b/Optimum.Render.Vulkan.Tests/GpuTest.cs new file mode 100644 index 00000000..232f1498 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/GpuTest.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The one place GPU tests get a or a +/// from. +/// +/// Every context and device comes up with the validation layers and, by +/// default, synchronization validation plus best practices ("sync,best"). +/// Plain validation missed the R32F-history and masked-clear bugs that only +/// synchronization validation names (TAA P2 and P4, 2026-09-10/11), so the +/// suite runs with it unless OPTIMUM_TEST_VALIDATION_FEATURES says otherwise; +/// an empty value turns the extra features off. +/// +internal static class GpuTest +{ + public const string ValidationFeaturesVariable = "OPTIMUM_TEST_VALIDATION_FEATURES"; + public const string DefaultValidationFeatures = "sync,best"; + + public static string ValidationFeatures => + Environment.GetEnvironmentVariable(ValidationFeaturesVariable) ?? DefaultValidationFeatures; + + /// Headless, validated options; receives every layer message. + public static VulkanContextOptions ContextOptions(List? messages = null) => new() + { + Headless = true, + EnableValidation = true, + ValidationFeatures = ValidationFeatures, + DebugCallback = messages == null ? null : Recorder(messages), + }; + + /// + /// Appends under the list's own lock: the layers call back from whichever + /// thread made the Vulkan call, and the asserts snapshot under the same lock. + /// + public static Action Recorder(List messages) => message => + { + lock (messages) messages.Add(message); + }; + + public static bool TryCreateContext(ITestOutputHelper output, List? messages, out VulkanContext? context) + { + bool created = VulkanContext.TryCreate(ContextOptions(messages), out context, out string? failureReason); + if (!created) output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + private static readonly ConditionalWeakTable> DeviceMessages = new(); + + /// + /// A device, not yet initialised, whose context will come up with the + /// suite's validation features and record every layer message for + /// . The client's own diagnostics channel still + /// receives them too. + /// + public static VulkanDevice NewDevice() + { + var messages = new List(); + // 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; + options.ValidationFeatures = ValidationFeatures; + Action? client = options.DebugCallback; + options.DebugCallback = message => + { + lock (messages) messages.Add(message); + client?.Invoke(message); + }; + }; + DeviceMessages.Add(device, messages); + return device; + } + + public static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device) + { + VulkanDevice created = NewDevice(); + 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; + } + + /// The layer messages a device from has recorded so far. + public static List MessagesOf(VulkanDevice seam) => + seam is VulkanDevice device && DeviceMessages.TryGetValue(device, out List? messages) + ? messages + : new List(); + + /// + /// A device's equivalent of plus + /// : validation errors fail, + /// synchronization hazards fail unless pinned, and whatever else the device + /// reports as an error through GetError (failed Vulkan calls, rejected + /// shaders) fails too. + /// + public static void AssertClean(VulkanDevice seam, [CallerFilePath] string callerFile = "") + { + List messages = MessagesOf(seam); + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages, callerFile); + + string? diagnostics = seam.GetError(); + if (string.IsNullOrEmpty(diagnostics)) return; + + // GetError repeats the layer messages (sanitised for the client's + // string.Format); those were judged above, so only the rest counts here. + string residual = diagnostics; + foreach (string message in ValidationAssert.Snapshot(messages)) + { + residual = residual.Replace(message.Replace('{', '[').Replace('}', ']'), ""); + } + + var remaining = new List(); + foreach (string line in residual.Split('\n')) + { + if (line.Trim().Length > 0) remaining.Add(line); + } + Assert.True(remaining.Count == 0, "device diagnostics:\n" + string.Join("\n", remaining)); + } +} 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/KnownSyncHazards.cs b/Optimum.Render.Vulkan.Tests/KnownSyncHazards.cs new file mode 100644 index 00000000..14ad4e8a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/KnownSyncHazards.cs @@ -0,0 +1,34 @@ +using System; + +namespace Optimum.Render.Vulkan.Tests; + +/// A synchronization-validation message a test is known to produce today. +/// The layer's message id, e.g. SYNC-HAZARD-WRITE-AFTER-WRITE. +/// The test class that produces it. +/// The test method that produces it. +/// The backend defect behind it. +/// The plan phase that removes the defect: "1B" or "2". +internal sealed record KnownSyncHazard(string Id, string TestClass, string TestMethod, string Defect, string RetiredBy); + +/// +/// Synchronization hazards the renderer produces today, pinned per test so a +/// new one fails () and a fixed +/// one must be deleted (): the list can +/// only shrink. Nothing here is fixed by editing the list; each entry is +/// retired by the plan phase it names. +/// +internal static class KnownSyncHazards +{ + public static readonly KnownSyncHazard[] Entries = + { + }; + + public static bool Covers(string id, string testClass, string testMethod) + { + foreach (KnownSyncHazard entry in Entries) + { + if (entry.Id == id && entry.TestClass == testClass && entry.TestMethod == testMethod) return true; + } + return false; + } +} 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/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 new file mode 100644 index 00000000..ce0e0e73 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/MeshManagerTests.cs @@ -0,0 +1,538 @@ +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) => + GpuTest.TryCreateContext(output, messages, out context); + + [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!); + 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!); + 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() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + var state = new PipelineKeyState(); + using var meshes = new MeshManager(context!); + + // 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 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 + // 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. + /// + /// 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 TheSsboPathBindsOnlyTheColoursTheChunkShadersDeclare() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + 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. + int mesh = meshes.CreateEmpty( + 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 position buffer still exists, and still carries the records. + Assert.NotNull(created.Buffers[MeshManager.BufferXyz]); + Assert.DoesNotContain(MeshManager.BufferXyz, created.BindingOrder); + + // 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 PipelineKeyState(); + using var meshes = new MeshManager(context!); + + 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 PipelineKeyState(); + using var meshes = new MeshManager(context!); + + 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); + } + } + + [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 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); + 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 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); + 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + using var pipelines = new GraphicsPipelineCache(context!); + 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); + + // 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 = targets.FormatsOf(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]); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + private static void SetDynamicDefaults(Vk api, CommandBuffer commandBuffer) + { + api.CmdSetCullMode(commandBuffer, CullModeFlags.None); + api.CmdSetFrontFace(commandBuffer, PipelineKeyState.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, SetupQueue 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/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.Tests/MotionWindowTests.cs b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs new file mode 100644 index 00000000..bb5d86f3 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs @@ -0,0 +1,380 @@ +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" }; + + /// 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); + + 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 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. The stated draw declares its pass without the + /// sampled slot before any scope opens, so nothing splits on either path. + /// + [SkippableTheory] + [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; + 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); // inference: 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} frameGraph={frameGraph} splits_after_compose={splitsAfterCompose} mask_restarts={maskRestarts}"); + 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"); + AssertEveryPixel(glowAfterWrite, 4, new byte[] { 255, 0, 0, 255 }, "glow written after it rejoined"); + + GpuTest.AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs new file mode 100644 index 00000000..56e692de --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs @@ -0,0 +1,447 @@ +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. + /// + [SkippableFact] + public unsafe void ThePlainBlitMatchesTheOpenGlBodyAsOneNativeDraw() + { + using Session session = Open(); + + byte[] stated = RunFrame(session, native: false, debugView: 0, fsr: false); + + long drawsBefore = session.Seam.NativeDrawsForTests; + long passesBefore = session.Seam.NativePassesForTests; + 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(stated, 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[] stated = RunFrame(session, native: false, debugView: mode, fsr: false); + + long drawsBefore = session.Seam.NativeDrawsForTests; + byte[] nativeRoute = RunFrame(session, native: true, debugView: mode, fsr: false); + + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + Assert.Equal(stated, 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[] stated = RunFrame(session, native: false, debugView: 0, fsr: true); + + long drawsBefore = session.Seam.NativeDrawsForTests; + long passesBefore = session.Seam.NativePassesForTests; + byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: true); + + Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore); + + 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 generic stated route, and the native route is not. + [SkippableFact] + public unsafe void TheOpenGlBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot() + { + using Session session = Open(); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; + RunFrame(session, native: false, debugView: 0, fsr: false); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); + + RunFrame(session, native: true, debugView: 0, fsr: false); + + 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/NativeChunkTests.cs b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs new file mode 100644 index 00000000..46344aa0 --- /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 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). +/// +/// 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 stated 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 ANativeChunkGroupDrawsWhatTheStatedGroupDraws(string pass, bool blend, bool cull) + { + using Session session = Open(motion: false); + + 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 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 stated 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[][] 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 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); + } + + /// + /// 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[][] 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(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"); + 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[] stated = session.RunShadowGroup(native: false); + byte[] native = session.RunShadowGroup(native: true); + + 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 stated 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 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) + { + 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 stated 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.Tests/NativeEntityDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs new file mode 100644 index 00000000..3cedafb4 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs @@ -0,0 +1,620 @@ +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 nothing else. + /// + [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[][] stated = session.RunFrame(native: false, motionOpen); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + byte[][] native = session.RunFrame(native: true, motionOpen); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + + for (int slot = 0; slot < stated.Length; slot++) + { + output.WriteLine("slot " + slot + " stated " + Centre(stated[slot]) + + " native " + Centre(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. + Assert.NotEqual(session.ClearOf(0), Centre(native[0])); + 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 + /// 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[] statedOpen = session.RunFrame(native: false, motionOpen: true)[Session.MotionSlot]; + byte[] nativeOpen = session.RunFrame(native: true, motionOpen: true)[Session.MotionSlot]; + 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[] statedShut = session.RunFrame(native: false, motionOpen: false)[Session.MotionSlot]; + byte[] nativeShut = session.RunFrame(native: true, motionOpen: false)[Session.MotionSlot]; + Assert.Equal(statedShut, nativeShut); + Assert.Equal(session.ClearOf(Session.MotionSlot), Centre(nativeShut)); + GpuTest.AssertClean(session.Seam); + } + + /// + /// 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 TheNeutralBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot() + { + using Session session = Open(gbuffer: false); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + session.RunFrame(native: false, motionOpen: true); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + + session.RunFrame(native: true, motionOpen: true); + 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 stated route. + session.RunFrame(native: true, motionOpen: true); + byte[] posed = session.RunFrame(native: true, motionOpen: true)[0]; + byte[] posedStated = session.RunFrame(native: false, motionOpen: true)[0]; + + Assert.NotEqual(session.ClearOf(0), Centre(posed)); + Assert.Equal(posed, posedStated); + 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 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]; + 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); + + // 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 + // 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; + ShaderPrograms.Entityanimated = previousEntityProgram!; + 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 + // stated 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.Tests/NativeGuiTests.cs b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs new file mode 100644 index 00000000..bf7d7013 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs @@ -0,0 +1,643 @@ +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, and the same pixels. + /// + [SkippableTheory] + [InlineData(true)] + [InlineData(false)] + public unsafe void TheNativeTextureBlitMatchesTheSeamsNeutralBody(bool blend) + { + using Session session = Open(); + + byte[] stated = session.RunTextureQuad(native: false, blend); + + long passesBefore = session.Seam.NativePassesForTests; + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + byte[] native = session.RunTextureQuad(native: true, blend); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + + output.WriteLine("blit centre stated " + Centre(stated) + " native " + Centre(native)); + Assert.Equal(stated, 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[] stated = session.RunOverlayLines(native: false, lineWidth); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + byte[] native = session.RunOverlayLines(native: true, lineWidth); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + + 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 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 TheNeutralBodiesDrawThroughTheStatedRouteAndTheNativeRouteDoesNot() + { + using Session session = Open(); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + 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.Platform.StatedDrawsForTests - statedBefore > 0); + + session.RunTextureQuad(native: true, blend: true); + session.RunOverlayLines(native: true, 1.0f); + 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); + } + + /// + /// 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 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[] stated = session.RunSelfBlit(native: false); + + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + byte[] native = session.RunSelfBlit(native: true); + + Assert.Equal(2, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + + 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(stated[left + 1], stated[right + 1]); + 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 stated 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 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. + /// + 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.Tests/NativeMeshDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs new file mode 100644 index 00000000..dfcecbb1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeMeshDrawTests.cs @@ -0,0 +1,523 @@ +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 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 +/// stated 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 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 ANativeMeshDrawMatchesTheStatedDrawOfTheSameMesh() + { + using Session session = Open(); + + byte[] stated = session.RunStatedFrame(); + + long meshDrawsBefore = session.Device.NativeMeshDrawsForTests; + long fullscreenBefore = session.Device.NativeFullscreenDrawsForTests; + byte[] native = session.RunNativeFrame(); + + Assert.Equal(1, session.Device.NativeMeshDrawsForTests - meshDrawsBefore); + Assert.Equal(0, session.Device.NativeFullscreenDrawsForTests - fullscreenBefore); + + output.WriteLine("stated centre: " + Centre(stated) + " native centre: " + Centre(native)); + Assert.Equal(stated, 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 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] + 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 stated route: the GL-shaped state, then DrawMesh. + public unsafe byte[] RunStatedFrame() + { + 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/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs new file mode 100644 index 00000000..19e3827a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs @@ -0,0 +1,1560 @@ +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. 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; +/// 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", "taa-sharpen", "blit", + "findbright", "blur", "godrays", "luma", "final", + }; + + /// + /// 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 + { + public ChainPlatform() : base(null!) + { + } + + /// + /// 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 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) + { + if (framebuffer == EnumFrameBuffer.Primary) + { + 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); + } + } + + // ------------------------------------------------------------------ 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 stated = RunMerge(session, native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + Frame nativeRoute = RunMerge(session, native: true); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + + 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. + 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 stated = RunMerge(session, native: false); + Frame nativeRoute = RunMerge(session, native: true); + + 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); + } + + /// + /// 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 stated = RunSkyMotion(session, native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + Frame nativeRoute = RunSkyMotion(session, native: true); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + + 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. + 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 generic stated route; the native chain is not. + [SkippableFact] + public void TheOpenGlRouteDrawsThroughTheStatedRouteAndTheNativeChainDoesNot() + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; + RunMerge(session, native: false); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); + + RunMerge(session, native: true); + + 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); + } + + /// + /// 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). + /// + /// 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. + /// + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void TheTaaResolveMatchesTheOpenGlBody(bool warmHistory) + { + using Session session = Open(); + session.EnableTaa(jitterActive: true); + session.PatternedScene = true; + + Resolved stated = RunResolve(session, native: false, warmHistory); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + Resolved nativeRoute = RunResolve(session, native: true, warmHistory); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore); + + 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. + 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[] stated = RunSharpen(session, native: false, out int statedTexture); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + 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(session.Sharpen.ColorTextureIds[0], statedTexture); + Assert.Equal(session.Sharpen.ColorTextureIds[0], nativeTexture); + Assert.Equal(stated, 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[] statedFive = RunResolveFrames(session, native: false, frames: 5, startPhase: 0); + Assert.Equal(statedFive, fiveFrames); + + GpuTest.AssertClean(session.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 stated = RunTail(session, native: false, aoInScene: gtao, aoTexture: aoTexture); + + long passesBefore = session.Seam.NativePassesForTests; + 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 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(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created); + + 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); + + 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; + + 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(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); + + /// 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; + } + + /// 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(); + // The history the resolve starts from, so both routes run the chain from identical + // contents; SeedFrame leaves the history alone so frames can accumulate across it. + session.SeedHistory(); + 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) + { + 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 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)! + .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!; + 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(); + + /// 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 scenePattern; + 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 ShaderProgram? sharpenBefore; + 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(); + + 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, + sharpenBefore = ShaderPrograms.TaaSharpen, + 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 _) => { }); + 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.TaaSharpen = sharpenBefore!; + 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); + 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); + + SeedPostTargets(); + + seam.BindFramebuffer(Primary.FboId); + seam.SetDrawBuffers(Primary.FboId, 0b111); + 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); + + 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); + } + + /// 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: 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. The TAA history is NOT seeded here - a frame of + /// the chain must be able to run after another one and find the history the previous + /// frame wrote, which is what the accumulation test measures. Seed it with + /// where a run needs a known starting history. + /// + 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); + } + } + + /// + /// 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); + + /// + /// 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) + { + 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 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. + /// + 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, Size, Size); + + public byte[] ReadGlow() => Decode(Primary.ColorTextureIds[1], Size, Size, motion: false); + + public byte[] ReadMotion() => Decode(Primary.ColorTextureIds[2], Size, Size, motion: true); + + public byte[] ReadHistoryColor(int parity) => Decode(History(parity).ColorTextureIds[0], Size, Size, motion: false); + + public byte[] ReadHistoryGlow(int parity) => Decode(History(parity).ColorTextureIds[1], Size, Size, 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], Size, Size, motion: true); + + public byte[] ReadSharpen() => Decode(Sharpen.ColorTextureIds[0], Size, Size, motion: false); + + /// One post target's colour 0, decoded at that target's own size. + public byte[] ReadPostTarget(int index) + { + 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, width, height, (IntPtr)destination); + } + return pixels; + } + + /// + /// 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 byte[] Decode(int textureId, int width, int height, bool motion) + { + VulkanDevice seam = Seam; + 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, width, height); + seam.SetDepthTest(false); + seam.SetDepthMask(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.DrawFullscreenTriangle(); + 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) + { + 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; + // 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(); + Sharpen = SingleTarget(Size, Size, 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); + 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(int width, int height, EnumTextureInternalFormat format) + { + var target = new FrameBufferRef + { + 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); + 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 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[] + { + "renderSize", "jitterPx", "invViewProjJittered", "prevViewProj", "viewMatrix", + "cameraDelta", "resetHistory", "blendAlpha", "varianceGamma", + }); + 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.TaaSharpen = sharpen; + ShaderPrograms.Blit = blit; + ShaderPrograms.Findbright = findbright; + ShaderPrograms.Blur = blur; + ShaderPrograms.Godrays = godrays; + ShaderPrograms.Luma = luma; + ShaderPrograms.Final = final; + + 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(); + SeedHistory(); + SeedSharpen(); + SceneSeed = ReadScene(); + GlowSeed = ReadGlow(); + MotionSeed = ReadMotion(); + HistorySeedColor = ReadHistoryColor(0); + SharpenSeed = ReadSharpen(); + 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.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/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/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/NativeShaderParityTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs new file mode 100644 index 00000000..6142153f --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs @@ -0,0 +1,557 @@ +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); + /// + /// 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(); + 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(); + 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); + } + 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"); + // 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(); + 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/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs new file mode 100644 index 00000000..04fd12bc --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeShaderRuntimeTests.cs @@ -0,0 +1,691 @@ +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, + ["OPTIMUM_OPTIMUMAO"] = corpus.OptimumAo, + }; + 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"))); + + 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); + } + + [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 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() + { + 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); + } + } + + /// + /// 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)); + 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, + 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; + 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/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/NativeSkyTests.cs b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs new file mode 100644 index 00000000..16bb78aa --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeSkyTests.cs @@ -0,0 +1,420 @@ +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, and the same scene and glow pixels. + /// + [SkippableFact] + public unsafe void TheNativeSkyPassMatchesTheSeamsNeutralBody() + { + using Session session = Open(); + + (byte[] statedScene, byte[] statedGlow) = session.RunFrame(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests; + (byte[] nativeScene, byte[] nativeGlow) = session.RunFrame(native: true); + + Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore); + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore); + + 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 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 TheNeutralBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot() + { + using Session session = Open(); + + long nativeDrawsBefore = session.Seam.NativeDrawsForTests; + long statedBefore = session.Platform.StatedDrawsForTests; + session.RunFrame(native: false); + Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore); + Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0); + + session.RunFrame(native: true); + 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 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); + 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/NativeSsaoChainTests.cs b/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs new file mode 100644 index 00000000..6d1ccbc8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeSsaoChainTests.cs @@ -0,0 +1,827 @@ +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 stated = session.Run(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + 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(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. + 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 stated = session.Run(native: false); + Frame nativeRoute = session.Run(native: true); + + 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(stated.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 statedHalf = session.Run(native: false); + Frame nativeHalf = session.Run(native: true); + Assert.Equal(statedHalf.Raw, nativeHalf.Raw); + Assert.Equal(statedHalf.Blurred, nativeHalf.Blurred); + Assert.Equal(statedHalf.Scene, nativeHalf.Scene); + + session.SsaaLevel = 0.75f; + Frame stated = session.Run(native: false); + Frame nativeRoute = session.Run(native: true); + 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(statedHalf.Raw, stated.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 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(stated.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 stated = session.Run(native: false); + + long passesBefore = session.Seam.NativePassesForTests; + long drawsBefore = session.Seam.NativeDrawsForTests; + 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(stated.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) stated = session.Viewport; + session.Run(native: true); + + Assert.Equal(stated, 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)Platform.stated.Viewport.Extent.Width, (int)Platform.stated.Viewport.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.Tests/NativeStatedTests.cs b/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs new file mode 100644 index 00000000..7f2b80de --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeStatedTests.cs @@ -0,0 +1,437 @@ +using System; +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; +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, 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. +/// +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 hand-stated reference draws, and records one native draw. + /// + [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 TheStatedRouteDrawsWhatTheHandStatedReferenceDraws(bool blend, EnumBlendMode mode, Mask mask, bool scissor) + { + using Session session = Open(); + + byte[] reference = session.Run(stated: false, blend, mode, mask, scissor); + + long statedBefore = session.Platform.StatedDrawsForTests; + byte[] native = session.Run(stated: true, blend, mode, mask, scissor); + + Assert.Equal(1, session.Platform.StatedDrawsForTests - statedBefore); + 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 - 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[] firstReference, byte[] secondReference) = session.RunTwoTargets(stated: false); + (byte[] firstStated, byte[] secondStated) = session.RunTwoTargets(stated: true); + + 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; + 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, 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.BeginFrame(); + Prepare(target); + 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 + { + 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.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.BeginFrame(); + Prepare(twoTargets); + 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(); + 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); + } + + /// + /// 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; + 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 new file mode 100644 index 00000000..78436fa8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/NativeWorldSystemsTests.cs @@ -0,0 +1,823 @@ +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, and the same pixels on every attachment. + /// + [SkippableFact] + public void TheNativeNightSkyPassMatchesTheSeamsNeutralBody() + { + using Session session = Open("nightsky"); + int cube = session.CubeGradient(); + + 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; + 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); + AssertSameAttachments(stated, 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[][] 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; + 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); + AssertSameAttachments(stated, native, "celestialobject"); + 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[][] stated = session.RunFrame(native: false, blending: true, depth: false, motion: false, Draw); + + long meshes = session.Seam.NativeMeshDrawsForTests; + byte[][] native = session.RunFrame(native: true, blending: true, depth: false, motion: false, Draw); + + Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshes); + AssertSameAttachments(stated, 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() + { + using Session session = Open("particlescube"); + + 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; + 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); + AssertSameAttachments(stated, native, "particlescube", mustDraw: false); + 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[][] 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(stated[MotionSlot], native[MotionSlot]); + AssertSameAttachments(stated, native, "particlescube (motion window)", mustDraw: false); + 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[][] 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 + // 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 + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); + + long indirect = session.Seam.NativeIndirectDrawsForTests; + byte[][] native = session.RunFrame(native: true, blending: true, depth: true, motion: false, + s => + { + // 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 + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); + + Assert.Equal(1, session.Seam.NativeIndirectDrawsForTests - indirect); + AssertSameAttachments(stated, 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[][] 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 + // 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 + { + 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 => + { + // 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 + { + s.Platform.RenderMesh(s.Mesh, starts, sizes, 2, false); + } + finally + { + s.Platform.EndDecalPass(); + } + }); + + 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 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 TheNeutralBodiesDrawThroughTheStatedRouteAndTheNativeRouteDoesNot() + { + using Session session = Open("particlescube"); + + long nativeBefore = session.Seam.NativeDrawsForTests; + 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.Platform.StatedDrawsForTests - statedBefore > 0); + + session.RunFrame(native: true, blending: true, depth: true, motion: false, + s => s.Platform.RenderParticles(s.Mesh, 2, 0)); + GpuTest.AssertClean(session.Seam); + } + + /// + /// 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 TheDeclaredColourSlotsAreTheOnesTheStatedMaskWouldHold() + { + 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 + + /// 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[][] stated, byte[][] native, string what, bool mustDraw = true) + { + for (int slot = 0; slot < stated.Length; slot++) + { + output.WriteLine(what + " slot " + slot + " centre stated " + Centre(stated[slot]) + + " native " + Centre(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. + if (mustDraw) Assert.NotEqual(ClearedSceneCentre, Centre(native[SceneSlot])); + } + + 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 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; + + 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); + + // 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; + } + + 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); + 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); + SeedFrameGlobals(seam, program.ProgramId); + SeedDrawUniforms(seam, program.ProgramId); + + 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; + } + + /// + /// 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) + { + 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", "standard" }) + { + 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.Tests/Optimum.Render.Vulkan.Tests.csproj b/Optimum.Render.Vulkan.Tests/Optimum.Render.Vulkan.Tests.csproj new file mode 100644 index 00000000..3666a7b0 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/Optimum.Render.Vulkan.Tests.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + false + true + annotations + + + + + + + + + + + + + + + + + + + + ..\.vanilla\win-x64\vintagestory\VintagestoryAPI.dll + true + + + + diff --git a/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs new file mode 100644 index 00000000..97a43af7 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PacingStatsTests.cs @@ -0,0 +1,540 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; +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 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 the GPU case Phase 1B step 3 flipped: an upload inside a frame +/// never blocks. +/// +public class PacingStatsTests +{ + private readonly ITestOutputHelper _output; + + public PacingStatsTests(ITestOutputHelper output) => _output = output; + + // ------------------------------------------------------------ interval ring + + [Fact] + public void RingPercentilesAreNearestRankAndStddevIsPopulation() + { + var ring = new FrameIntervalRing(512); + for (int i = 1; i <= 100; i++) ring.Add(i); + + FramePacingSnapshot s = ring.Snapshot(); + Assert.Equal(100, s.Samples); + Assert.Equal(50, s.P50); + Assert.Equal(95, s.P95); + Assert.Equal(99, s.P99); + // Population stddev of 1..100 is sqrt((n^2 - 1) / 12). + Assert.Equal(Math.Sqrt((100.0 * 100.0 - 1.0) / 12.0), s.StdDev, 9); + // Nothing above 2 x p50 = 100. + Assert.Equal(0, s.Stutters); + } + + [Fact] + public void RingCountsStuttersAboveTwiceTheMedian() + { + var ring = new FrameIntervalRing(512); + for (int i = 0; i < 95; i++) ring.Add(10); + ring.Add(20); // exactly 2 x p50: not a stutter + ring.Add(20.001); + ring.Add(45); + ring.Add(90); + ring.Add(33); + ring.Add(21); + + Assert.Equal(5, ring.Snapshot().Stutters); + } + + [Fact] + public void RingWrapKeepsOnlyTheNewestFrames() + { + var small = new FrameIntervalRing(4); + for (int i = 1; i <= 6; i++) small.Add(i); + FramePacingSnapshot s = small.Snapshot(); + Assert.Equal(4, s.Samples); + // {3, 4, 5, 6}: 1 and 2 were overwritten. + Assert.Equal(4, s.P50); + Assert.Equal(6, s.P95); + Assert.Equal(6, s.P99); + Assert.Equal(Math.Sqrt(1.25), s.StdDev, 9); + + var ring = new FrameIntervalRing(FrameIntervalRing.DefaultCapacity); + for (int i = 0; i < 88; i++) ring.Add(1000); + for (int i = 0; i < 512; i++) ring.Add(10); + s = ring.Snapshot(); + Assert.Equal(512, s.Samples); + Assert.Equal(10, s.P99); + Assert.Equal(0, s.StdDev); + Assert.Equal(0, s.Stutters); + + // Wrapping past the start again replaces the oldest 10s, not the newest. + for (int i = 0; i < 5; i++) ring.Add(25); + s = ring.Snapshot(); + Assert.Equal(512, s.Samples); + Assert.Equal(5, s.Stutters); + Assert.Equal(10, s.P50); + Assert.Equal(10, s.P99); + } + + [Fact] + public void RingIgnoresInvalidIntervalsAndSnapshotsEmpty() + { + var ring = new FrameIntervalRing(8); + Assert.Equal(default, ring.Snapshot()); + ring.Add(double.NaN); + ring.Add(-1); + ring.Add(double.PositiveInfinity); + Assert.Equal(0, ring.Count); + } + + // --------------------------------------------------------------- line tokens + + [Fact] + public void OriginalStatsLineKeepsItsFormat() + { + string line = VulkanStats.FormatIntervalLine(1.0, 60, 2, 10, 1, 20.0, 3, 4, 5, 6); + Assert.Equal( + "stats 1.0s: 60 frames (16.7 ms/frame), 2 allocations (10 live), " + + "1 blocking uploads costing 20 ms (2% of the interval), textures +3/-4, " + + "mesh writes dropped 5, uniform overflows 6", + line); + } + + [Fact] + public void NewStatsLinesCarryStableKeyValueTokens() + { + Assert.Equal( + "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, 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, 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 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 " + + "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_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, 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 " + + "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); + Assert.Equal("swapchain_acquire", VulkanStats.WaitSiteTokens[(int)WaitSite.SwapchainAcquire]); + Assert.Equal("present", VulkanStats.WaitSiteTokens[(int)WaitSite.Present]); + Assert.Equal("queue_submit", VulkanStats.WaitSiteTokens[(int)WaitSite.QueueSubmit]); + } + + [Fact] + public void SampleIsTheOriginalLineFollowedByTheTokenLines() + { + // The first call may only arm the interval clock. + VulkanStats.SampleIfDue(TimeSpan.Zero); + string? sample = VulkanStats.SampleIfDue(TimeSpan.Zero); + + Assert.NotNull(sample); + string[] lines = sample!.Split('\n'); + 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[8]); + // Caching follow-ups: pipelines compiled blocking/async/prewarmed, skipped draws, cache bytes, saves. + 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[6]); + // Phase 1B step 5: pool classes, ReBAR use and misses, used/budget per heap. + 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+, " + + @"mesh writes dropped \d+, uniform overflows \d+$"), lines[0]); + Assert.StartsWith("stats.pacing samples=", lines[1]); + Assert.StartsWith("stats.waits frame_pacing_n=", lines[2]); + Assert.StartsWith("stats.counters blocking_uploads=", lines[3]); + } + + [Fact] + public void AcceptanceDocumentNamesEveryStatsToken() + { + string doc = File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, "docs", "taa-acceptance.md")); + + foreach (string line in new[] + { + VulkanStats.FormatPacingLine(default), + VulkanStats.FormatCountersLine(default), + 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_]+)=")) + { + Assert.Contains(token.Groups[1].Value + "=", doc); + } + } + foreach (string site in VulkanStats.WaitSiteTokens) + { + Assert.Contains("`" + site + "`", doc); + } + Assert.Contains("stats.pacing", doc); + Assert.Contains("stats.waits", doc); + Assert.Contains("stats.counters", doc); + Assert.Contains("stats.memory", doc); + Assert.Contains("stats.transients", doc); + Assert.Contains("stats.latency", doc); + } + + [Fact] + public void PacingGateReadsTheLinesThisBackendWrites() + { + string dir = Directory.CreateTempSubdirectory("optimum-pacing-").FullName; + try + { + string fps = Path.Combine(dir, "fps.log"); + string stats = Path.Combine(dir, "vulkan-stats.log"); + File.WriteAllText(fps, + "[Optimum] fps window=1.002 frames=120 mean=8.350 min=7.900 max=10.100 p99=9.800 stddev=0.500\n" + + "[Optimum] fps window=1.001 frames=121 mean=8.300 min=7.800 max=10.000 p99=9.700 stddev=0.480\n"); + + File.WriteAllText(stats, Sample(blockingUploads: 0) + "\n" + Sample(blockingUploads: 0) + "\n"); + (int code, string output) = RunGate("--renderer", "vulkan", "--fps", fps, "--stats", stats); + _output.WriteLine(output); + Assert.True(code == 0, output); + Assert.Contains("max 0 (0 of 2 samples non-zero)", output); + + File.WriteAllText(stats, Sample(blockingUploads: 0) + "\n" + Sample(blockingUploads: 2) + "\n"); + (code, output) = RunGate("--renderer", "vulkan", "--fps", fps, "--stats", stats); + _output.WriteLine(output); + Assert.True(code == 1, output); + Assert.Matches(new Regex(@"blocking_uploads\s+max 2 \(1 of 2 samples non-zero\).*FAIL"), output); + } + finally + { + Directory.Delete(dir, true); + } + + static string Sample(long blockingUploads) => + VulkanStats.FormatIntervalLine(1.0, 120, 0, 812, 0, 0, 0, 0, 0, 0) + "\n" + + VulkanStats.FormatPacingLine(new FramePacingSnapshot(512, 8.3, 9.8, 10.7, 0.6, 0)) + "\n" + + VulkanStats.FormatWaitsLine(new long[VulkanStats.WaitSiteCount], new double[VulkanStats.WaitSiteCount]) + "\n" + + VulkanStats.FormatCountersLine(new CounterSample(blockingUploads, 0, 2640, 240, 0, 168000, 402112, 16777216)); + } + + private static (int Code, string Output) RunGate(params string[] arguments) + { + var start = new ProcessStartInfo("bash") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = ShaderCorpus.RepositoryRoot, + }; + start.ArgumentList.Add(Path.Combine(ShaderCorpus.RepositoryRoot, "scripts", "dev", "pacing-gate.sh")); + foreach (string argument in arguments) start.ArgumentList.Add(argument); + + using Process process = Process.Start(start)!; + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdout + stderr); + } + + // ------------------------------------------------------ wait-site coverage + + private static string Source(string relative) => + File.ReadAllText(Path.Combine(ShaderCorpus.RepositoryRoot, "Optimum.Render.Vulkan", relative)); + + 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); + } + + 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; + } + + [Fact] + public void EveryCpuWaitOnTheGpuIsCountedAtItsSite() + { + string root = Path.Combine(ShaderCorpus.RepositoryRoot, "Optimum.Render.Vulkan"); + foreach (string file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories)) + { + if (file.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar)) continue; + string text = File.ReadAllText(file); + string name = Path.GetFileName(file); + + // vkDeviceWaitIdle only through the counting helper. + if (name != "VulkanStats.cs") Assert.DoesNotContain(".DeviceWaitIdle(", text); + // Every fence wait lives in a file that notes the wait. + 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 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. + string timeline = Source("Frame/FrameTimeline.cs"); + string wait = Body(timeline, "private void Wait("); + Assert.True(wait.IndexOf("WaitSemaphores(", StringComparison.Ordinal) < + wait.IndexOf("VulkanStats.NoteWait(site, waitStart);", StringComparison.Ordinal), + "the timeline wait must be counted after it returns"); + string frameRing = Source("Core/FrameRing.cs"); + Assert.DoesNotContain("WaitForFences(", frameRing); + Assert.DoesNotContain("Fence Fence", frameRing); + string ringBegin = Body(frameRing, "public FrameSlot BeginFrame("); + Assert.Equal(1, Count(ringBegin, "_timeline.WaitForFrame(")); + Assert.Contains("_timeline.WaitForFrame(slot.LastSignalledValue, WaitSite.FramePacing);", ringBegin); + Assert.Contains("_retired.Collect();", ringBegin); + Assert.Contains("_retired.Retire(resource)", frameRing); + // Phase 1B step 2: a partial submit never waits. + Assert.DoesNotContain("WaitForFrame(", Body(frameRing, "public ulong SubmitPartial()")); + string frameSubmit = Body(frameRing, "private void Submit("); + int submitStart = frameSubmit.IndexOf("long submitStart = VulkanStats.WaitStart();", StringComparison.Ordinal); + int queueLock = frameSubmit.IndexOf("lock (_context.QueueLock)", StringComparison.Ordinal); + int submitNoted = frameSubmit.IndexOf("VulkanStats.NoteWait(WaitSite.QueueSubmit, submitStart);", StringComparison.Ordinal); + Assert.True(submitStart >= 0 && queueLock > submitStart && submitNoted > queueLock, + "the frame submit must be timed from before the queue lock to after the 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("Present/Swapchain.cs"); + Assert.Contains("WaitSite.SwapchainAcquire", Body(swapchain, "public bool TryAcquire(")); + 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()")); + // Phase 1B step 2: no flush, no query wait, no device-idle wait on a readback. + Assert.DoesNotContain("FlushFrame", device); + Assert.DoesNotContain("WaitSite.OcclusionQuery", device); + Assert.DoesNotContain("WaitSite.FlushFrame", frameRing); + Assert.DoesNotContain("ResultWaitBit", Source("Frame/QueryRing.cs")); + string readBack = Body(device, "private void ReadBack("); + Assert.DoesNotContain("WaitDeviceIdle", readBack); + 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 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. 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); + 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 + // 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);")); + } + + // ------------------------------------------------------------------ GPU + + /// + /// 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 TextureUploadInsideAFrameNeverBlocks() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 4; + 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); + + var data = new byte[size * size * 4]; + for (int i = 0; i < size * size; i++) + { + data[i * 4] = 10; + data[i * 4 + 1] = 200; + data[i * 4 + 2] = (byte)(i * 16); + data[i * 4 + 3] = 255; + } + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + + 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; + long requestsDelta = VulkanStats.UploadRequests - requestsBefore; + 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); + var pixelsOut = new byte[size * size * 4]; + fixed (byte* destination = pixelsOut) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + + Assert.Equal(data, pixelsOut); + + Assert.Equal(1, requestsDelta); + 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); + + GpuTest.AssertClean(seam); + } + } + /// + /// 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() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 4; + 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); + + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + // No channel lands on x.5 in UNORM8: the spec lets 0.5 quantise to 127 or + // 128, and the RTX 4070 driver reads 127 (0.2 -> 51 is exact either way). + seam.ClearColor(0, 0.25f, 0.2f, 0.75f, 1f); + seam.Present(); + long submitsAfterFrame = VulkanStats.WaitCount(WaitSite.QueueSubmit); + + var pixelsOut = new byte[size * size * 4]; + fixed (byte* destination = pixelsOut) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + + for (int i = 0; i < pixelsOut.Length; i += 4) + { + Assert.Equal(new byte[] { 64, 51, 191, 255 }, pixelsOut[i..(i + 4)]); + } + Assert.Equal(1, submitsAfterFrame - submitsBefore); + Assert.Equal(submitsAfterFrame + 1, VulkanStats.WaitCount(WaitSite.QueueSubmit)); + + GpuTest.AssertClean(seam); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs b/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs new file mode 100644 index 00000000..482aa6ef --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ParityDumpTests.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Optimum.Render.Vulkan; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Xunit; +using Xunit.Abstractions; + +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 +/// shared writer, and decoded from the files. +/// +/// Every value is checked against the fragment that produced it, in GL row +/// order: file row k must be gl_FragCoord.y = k + 0.5. The size is odd and +/// non-square so a transposed or flipped image cannot pass, and the float +/// patterns carry HDR and negative values a clamp-to-byte decode would destroy. +/// +public class ParityDumpTests +{ + private const int Width = 13; + private const int Height = 7; + + private readonly ITestOutputHelper _output; + + public ParityDumpTests(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); + } + """; + + private const string Fragment = """ + #version 330 core + layout(location = 0) out vec4 color; + layout(location = 1) out vec4 hdr; + layout(location = 2) out vec4 linearDepth; + void main() { + int x = int(gl_FragCoord.x); + int y = int(gl_FragCoord.y); + color = vec4(float(x * 17) / 255.0, float(y * 31) / 255.0, + float((x + 3 * y) % 256) / 255.0, float(250 - x - 2 * y) / 255.0); + hdr = vec4(100.0 + float(x) * 1.5, -0.25 * float(y), float(x * y), 0.5 + float(x)); + linearDepth = vec4(1000.0 + float(x) * 0.125 + float(y) * 64.0, 0.0, 0.0, 1.0); + gl_FragDepth = (float(x + y * 13) + 0.5) / 91.0; + } + """; + + [SkippableFact] + public void RenderedPatternsDumpAndDecodeBackInGlRowOrder() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + string directory = Path.Combine(Path.GetTempPath(), "optimum-parity-dump-tests-" + Guid.NewGuid().ToString("N")); + try + { + var files = new Dictionary(); + using (device) + { + VulkanDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, Vertex, Fragment, "parity-dump"); + + int rgba8 = seam.CreateTexture2D(Width, Height, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int rgba16f = seam.CreateTexture2D(Width, Height, EnumTextureInternalFormat.Rgba16f, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, false); + int r32f = seam.CreateTexture2DRaw(Width, Height, 0x822E, IntPtr.Zero, 4); + int depth = seam.CreateTexture2D(Width, Height, EnumTextureInternalFormat.DepthComponent32, + EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false); + + int framebuffer = seam.CreateFramebuffer(Width, Height); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, rgba8, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment1, rgba16f, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment2, r32f, 0); + seam.AttachTexture(framebuffer, EnumFramebufferAttachment.DepthAttachment, depth, 0); + seam.SetDrawBuffers(framebuffer, 7); + Assert.True(seam.CheckFramebufferComplete(framebuffer, out string status), status); + + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, Width, Height); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetDepthTest(true); + seam.SetDepthMask(true); + seam.SetDepthFunc(0x0207); // GL_ALWAYS + seam.UseProgram(program); + seam.DrawFullscreenTriangle(); + + // Readback inside the frame, after the draw and before Present - + // where the client calls it. + var attachments = new (string Label, int Texture)[] + { + ("color0", rgba8), ("color1", rgba16f), ("color2", r32f), ("depth", depth), + }; + foreach (var (label, texture) in attachments) + { + OptimumTextureReadback? readback = seam.ReadTextureForParity(texture); + Assert.NotNull(readback); + files[label] = OptimumParityDump.Write(directory, 0, "Primary", label, readback!); + } + seam.Present(); + GpuTest.AssertClean(seam); + } + + Assert.Equal(2, files["color0"]); + Assert.Equal(2, files["color1"]); + Assert.Equal(1, files["color2"]); + Assert.Equal(1, files["depth"]); + + string[] expectedNames = + { + "0-Primary-color0-rgba8.ppm", "0-Primary-color0-rgba8.pgm", + "0-Primary-color1-rgba16f.pfm", "0-Primary-color1-rgba16f.alpha.pfm", + "0-Primary-color2-r32f.pfm", "0-Primary-depth-depth.pfm", + }; + string[] actualNames = Directory.GetFiles(directory); + for (int i = 0; i < actualNames.Length; i++) actualNames[i] = Path.GetFileName(actualNames[i]); + Array.Sort(actualNames, StringComparer.Ordinal); + string[] sortedExpected = (string[])expectedNames.Clone(); + Array.Sort(sortedExpected, StringComparer.Ordinal); + Assert.Equal(sortedExpected, actualNames); + + // RGBA8: PPM of RGB, PGM of alpha, exact bytes. + byte[] rgb = ReadRaster(Path.Combine(directory, "0-Primary-color0-rgba8.ppm"), "P6", out _); + byte[] alpha = ReadRaster(Path.Combine(directory, "0-Primary-color0-rgba8.pgm"), "P5", out _); + for (int y = 0; y < Height; y++) + for (int x = 0; x < Width; x++) + { + int texel = y * Width + x; + Assert.Equal((byte)(x * 17), rgb[texel * 3]); + Assert.Equal((byte)(y * 31), rgb[texel * 3 + 1]); + Assert.Equal((byte)((x + 3 * y) % 256), rgb[texel * 3 + 2]); + Assert.Equal((byte)(250 - x - 2 * y), alpha[texel]); + } + + // RGBA16F: PF of RGB plus Pf of alpha, float32 little-endian, HDR and negatives intact. + float[] hdr = ReadFloats(Path.Combine(directory, "0-Primary-color1-rgba16f.pfm"), "PF"); + float[] hdrAlpha = ReadFloats(Path.Combine(directory, "0-Primary-color1-rgba16f.alpha.pfm"), "Pf"); + for (int y = 0; y < Height; y++) + for (int x = 0; x < Width; x++) + { + int texel = y * Width + x; + Assert.Equal(100f + x * 1.5f, hdr[texel * 3]); + Assert.Equal(-0.25f * y, hdr[texel * 3 + 1]); + Assert.Equal((float)(x * y), hdr[texel * 3 + 2]); + Assert.Equal(0.5f + x, hdrAlpha[texel]); + } + + // R32F: one channel, exact. + float[] linear = ReadFloats(Path.Combine(directory, "0-Primary-color2-r32f.pfm"), "Pf"); + for (int y = 0; y < Height; y++) + for (int x = 0; x < Width; x++) + { + Assert.Equal(1000f + x * 0.125f + y * 64f, linear[y * Width + x]); + } + + // Depth: one channel, the fragment's gl_FragDepth. + float[] depthValues = ReadFloats(Path.Combine(directory, "0-Primary-depth-depth.pfm"), "Pf"); + for (int y = 0; y < Height; y++) + for (int x = 0; x < Width; x++) + { + float expected = (x + y * 13 + 0.5f) / 91f; + Assert.InRange(depthValues[y * Width + x], expected - 1e-6f, expected + 1e-6f); + } + } + finally + { + if (Directory.Exists(directory)) + { + try { Directory.Delete(directory, recursive: true); } catch (IOException) { } + } + } + } + + /// Header: magic, width, height, maxval-or-scale, each on its own line. + private static byte[] ReadRaster(string path, string magic, out string scale) + { + 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(magic, header[0]); + Assert.Equal(Width.ToString(CultureInfo.InvariantCulture), header[1]); + Assert.Equal(Height.ToString(CultureInfo.InvariantCulture), header[2]); + scale = header[3]; + int channels = magic is "P6" or "PF" ? 3 : 1; + int bytesPerValue = magic.StartsWith("P", StringComparison.Ordinal) && magic[1] is 'F' or 'f' ? 4 : 1; + Assert.Equal(offset + Width * Height * channels * bytesPerValue, file.Length); + return file.AsSpan(offset).ToArray(); + } + + private static float[] ReadFloats(string path, string magic) + { + byte[] raster = ReadRaster(path, magic, out string scale); + // Negative scale: little-endian, the encoding the dump promises. + Assert.Equal("-1.0", scale); + var values = new float[raster.Length / 4]; + for (int i = 0; i < values.Length; i++) + { + values[i] = System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(raster.AsSpan(i * 4, 4)); + } + return values; + } +} 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.Tests/PerDrawCostTests.cs b/Optimum.Render.Vulkan.Tests/PerDrawCostTests.cs new file mode 100644 index 00000000..92b5a277 --- /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 sky; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(sky, 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(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 framebuffer; + } + + private static unsafe byte[] Read(VulkanDevice 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(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 void BaseState(VulkanDevice 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) + { + VulkanDevice 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 = device!.DynamicStateCommandsPerDrawForTests; + + 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) + { + VulkanDevice 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(VulkanDevice 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) + { + VulkanDevice 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, "sky", 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) + { + VulkanDevice 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, "sky", 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(VulkanDevice 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) + { + VulkanDevice 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..aadcb0f8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PerDrawCostUnitTests.cs @@ -0,0 +1,318 @@ +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.Everything, 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.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.Everything, cache.Update(3, values)); + + cache.Enabled = false; + Assert.Equal(DynamicStateDirty.Everything, 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() + { + // 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"); + 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() + { + // 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); + }); + + 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.Tests/PipelineCacheTests.cs b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs new file mode 100644 index 00000000..8c7c5e84 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PipelineCacheTests.cs @@ -0,0 +1,830 @@ +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; +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 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 +{ + private readonly ITestOutputHelper _output; + + public PipelineCacheTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context, List messages) => + GpuTest.TryCreateContext(output, messages, out context); + + 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); + SharedPipelineLayout shared = program.StandaloneLayout!; + foreach (DescriptorSetLayout layout in new[] { shared.FrameSetLayout, shared.TextureSetLayout, shared.StorageSetLayout }) + { + 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); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// + /// 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() + { + 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(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); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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 PipelineKeyState(); + 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}"); + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(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 PipelineKeyState(); + + 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"); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + finally + { + foreach (ShaderProgramResources resources in programs) resources.Dispose(); + } + } + } + + /// + /// 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 PipelineKeyState(); + 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) + { + } + } + } + } + + // ------------------------------------------------------ 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")); + } + + /// + /// 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. + /// + [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 PipelineKeyState(); + 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) + { + } + } + } + + /// + /// 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.Tests/PipelineKeyState.cs b/Optimum.Render.Vulkan.Tests/PipelineKeyState.cs new file mode 100644 index 00000000..3867ea07 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PipelineKeyState.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// 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 PipelineKeyState +{ + public const int MaxColorAttachments = RenderLimits.MaxColorAttachments; + public const int MaxTextureUnits = RenderLimits.MaxTextureUnits; + + private readonly AttachmentBlend[] _blend = new AttachmentBlend[MaxColorAttachments]; + private readonly Interner _blendSignatures = new(); + private readonly Interner _targetFormats = new(); + + private int _cachedBlendId = -1; + private int _cachedBlendCount = -1; + private ColorComponentFlags _colorWriteMask = + ColorComponentFlags.RBit | ColorComponentFlags.GBit + | ColorComponentFlags.BBit | ColorComponentFlags.ABit; + + public PipelineKeyState() + { + 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; } + + public const FrontFace FrontFace = RenderLimits.FrontFace; + + // -------------------------------------------------------------------- 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))); + + /// + /// 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; + + 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; + InvalidateBlend(); + } + + /// + /// 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) = + AttachmentBlend.FactorsFor(mode); + + 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; + } + 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; + InvalidateBlend(); + } + + /// + /// 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); + InvalidateBlend(); + } + + 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; + InvalidateBlend(); + } + + // ---------------------------------------------------------------------- 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) + { + 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; + } + + 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) => + 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: 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 => _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 => _dynamicBlend; + set + { + _dynamicBlend = value; + InvalidateBlend(); + } + } + + /// 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. + /// + public void Reset() + { + for (int i = 0; i < _blend.Length; i++) _blend[i] = AttachmentBlend.Default; + InvalidateBlend(); + _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.Tests/PlatformDeviceRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs new file mode 100644 index 00000000..cd6b2daa --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PlatformDeviceRoutingTests.cs @@ -0,0 +1,182 @@ +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."); + VulkanDevice 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.Tests/PlatformLeafRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs new file mode 100644 index 00000000..91ff59ff --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PlatformLeafRoutingTests.cs @@ -0,0 +1,448 @@ +using System; +using System.IO; +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; + +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; + // 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[] { 30, 200, 10, 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); + } + + 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/PlatformProgramRoutingTests.cs b/Optimum.Render.Vulkan.Tests/PlatformProgramRoutingTests.cs new file mode 100644 index 00000000..7590e524 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PlatformProgramRoutingTests.cs @@ -0,0 +1,310 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Platform; +using Vintagestory.API.Client; +using Vintagestory.API.Config; +using Vintagestory.API.MathTools; +using Vintagestory.Client; +using Vintagestory.Client.NoObf; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 3: ShaderProgramBase and UBO no longer talk to the +/// device or to GL; they call ScreenManager.Platform. These drive the lib's own classes +/// (the donor the Cecil patch transplants) through a VulkanClientPlatform installed as +/// the client's platform, and read the pixels back, so a virtual that stops reaching the +/// device shows up as a wrong colour rather than as a missing call. +/// +public class PlatformProgramRoutingTests +{ + private readonly ITestOutputHelper _output; + + public PlatformProgramRoutingTests(ITestOutputHelper output) => _output = output; + + private sealed class RoutedProgram : ShaderProgramBase + { + public override bool Compile() => true; + } + + 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); + } + """; + + /// The installed platform, the platform it replaced and the crash-marker directory. + private sealed class Session : IDisposable + { + private readonly ClientPlatformAbstract? _previous; + private readonly string _dataPath; + + public VulkanClientPlatform Platform { get; } + public VulkanDevice Seam => Platform.GraphicsDevice!; + + private Session(VulkanClientPlatform platform, ClientPlatformAbstract? previous, string dataPath) + { + Platform = platform; + _previous = previous; + _dataPath = dataPath; + } + + public static Session? TryOpen(ITestOutputHelper output) + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-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(); + TryDelete(dataPath); + return null; + } + + var session = new Session(platform, ScreenManager.Platform, dataPath); + ScreenManager.Platform = platform; + return session; + } + + public void Dispose() + { + ShaderProgramBase.CurrentShaderProgram = null; + ScreenManager.Platform = _previous!; + Platform.ShutdownGraphics(); + TryDelete(_dataPath); + } + + private static void TryDelete(string path) + { + try + { + Directory.Delete(path, true); + } + catch (DirectoryNotFoundException) + { + } + } + } + + private static int ColourTarget(VulkanDevice 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, 0b1); + return framebuffer; + } + + private static void BeginDraw(VulkanDevice seam, int framebuffer, int size) + { + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.ClearColor(0, 0f, 0f, 0f, 1f); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + } + + private static unsafe byte[] ReadCentre(VulkanDevice seam, int framebuffer, int size, bool openFrame) + { + var pixels = new byte[size * size * 4]; + if (openFrame) seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + fixed (byte* destination = pixels) + { + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)destination); + } + if (openFrame) seam.Present(); + int centre = (size / 2 * size + size / 2) * 4; + return new[] { pixels[centre], pixels[centre + 1], pixels[centre + 2], pixels[centre + 3] }; + } + + /// + /// Use, the scalar/vector/integer-vector/matrix setters, Stop and Dispose, each + /// called on the lib's ShaderProgramBase. Every uniform contributes to the colour, + /// so any one of them failing to reach the device changes the pixel. + /// + [SkippableFact] + public void UniformsSetOnAShaderProgramReachTheShaderThroughThePlatform() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanDevice seam = session!.Seam; + const int size = 16; + + var program = new RoutedProgram { PassName = "routed-uniforms" }; + program.ProgramId = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform float red; + uniform vec2 greenBlue; + uniform int alphaOn; + uniform ivec3 offsets; + uniform mat4 transform; + out vec4 outColor; + void main(void) + { + vec4 moved = transform * vec4(0.0, 0.0, 0.0, 1.0); + outColor = vec4(red, + greenBlue.x + float(offsets.y) / 255.0, + greenBlue.y + moved.x, + alphaOn == 1 ? 1.0 : 0.0); + } + """); + foreach (string name in new[] { "red", "greenBlue", "alphaOn", "offsets", "transform" }) + { + int location = seam.GetUniformLocation(program.ProgramId, name); + Assert.True(location >= 0, name + " has no location"); + program.uniformLocations[name] = location; + } + + int framebuffer = ColourTarget(seam, size); + var transform = new float[16]; + transform[0] = transform[5] = transform[10] = transform[15] = 1f; + transform[12] = 30f / 255f; // column-major translation x + + BeginDraw(seam, framebuffer, size); + program.Use(); + Assert.Same(program, ShaderProgramBase.CurrentShaderProgram); + program.Uniform("red", 60f / 255f); + program.Uniform("greenBlue", new Vec2f(100f / 255f, 150f / 255f)); + program.Uniform("alphaOn", 1); + program.Uniform("offsets", new Vec3i(0, 20, 0)); + program.UniformMatrix("transform", transform); + seam.DrawFullscreenTriangle(); + program.Stop(); + Assert.Null(ShaderProgramBase.CurrentShaderProgram); + seam.Present(); + + byte[] pixel = ReadCentre(seam, framebuffer, size, openFrame: false); + _output.WriteLine($"centre RGBA = {pixel[0]}, {pixel[1]}, {pixel[2]}, {pixel[3]}"); + Assert.Equal(new byte[] { 60, 120, 180, 255 }, pixel); + + program.Dispose(); + Assert.True(program.Disposed); + GpuTest.AssertClean(seam); + } + + /// + /// CreateUBO, then the object-range UBO update in three consecutive frames, each bound + /// by ShaderProgramBase.Use and unbound by Stop, with no readback between the frames; + /// then Dispose through the platform. + /// + [SkippableFact] + public void EveryUboUpdatePathReachesTheBlockThroughThePlatform() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanDevice seam = session!.Seam; + const int size = 16; + + var program = new RoutedProgram { PassName = "routed-ubo" }; + program.ProgramId = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + layout(std140) uniform Tint { vec4 tint; }; + out vec4 outColor; + void main(void) { outColor = tint; } + """); + + var ubo = Assert.IsType(session.Platform.CreateUBO(program.ProgramId, 0, "Tint", sizeof(float) * 4)); + Assert.True(ubo.Handle > 0); + Assert.Equal("Tint", ubo.BlockName); + program.ubos["Tint"] = ubo; + + var colours = new[] + { + new byte[] { 25, 75, 125 }, + new byte[] { 210, 15, 45 }, + new byte[] { 90, 160, 230 }, + }; + var framebuffers = new int[colours.Length]; + for (int frame = 0; frame < colours.Length; frame++) + { + framebuffers[frame] = ColourTarget(seam, size); + byte[] c = colours[frame]; + // The object-range overload is the one every caller uses (the entity + // renderers' bone upload). The generic Update overloads are not + // driven here: vanilla pins through GCHandleProvider.Pointer, which is + // GCHandle.ToIntPtr (the handle value, not the data address), so they + // upload garbage on GL and on the device alike, before and after this move. + ubo.Update(new[] { c[0] / 255f, c[1] / 255f, c[2] / 255f, 1f }, 0, sizeof(float) * 4); + + BeginDraw(seam, framebuffers[frame], size); + program.Use(); + seam.DrawFullscreenTriangle(); + program.Stop(); + seam.Present(); + } + + for (int frame = 0; frame < colours.Length; frame++) + { + byte[] pixel = ReadCentre(seam, framebuffers[frame], size, openFrame: true); + Assert.Equal(colours[frame][0], pixel[0]); + Assert.Equal(colours[frame][1], pixel[1]); + Assert.Equal(colours[frame][2], pixel[2]); + } + + ubo.Dispose(); + program.Dispose(); + GpuTest.AssertClean(seam); + } + + /// + /// BindTexture2D with a custom sampler: the program aims the sampler at the unit + /// through the platform (the device never reads uniformLocations for it), Stop clears + /// the sampler override, and Dispose deletes sampler and program. + /// + [SkippableFact] + public unsafe void ATextureBoundOnAShaderProgramIsSampledThroughThePlatform() + { + using Session? session = Session.TryOpen(_output); + Skip.If(session == null, "No usable Vulkan device."); + VulkanDevice seam = session!.Seam; + const int size = 16; + + var program = new RoutedProgram { PassName = "routed-texture" }; + program.ProgramId = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, """ + #version 330 core + uniform sampler2D source; + out vec4 outColor; + void main(void) { outColor = texture(source, vec2(0.5, 0.5)); } + """); + program.SetCustomSampler("source", false); + Assert.True(program.customSamplers["source"] > 0); + + var texel = new byte[] { 10, 200, 90, 255 }; + int texture; + fixed (byte* data = texel) + { + texture = seam.CreateTexture2D(1, 1, + EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)data, false); + } + int framebuffer = ColourTarget(seam, size); + + BeginDraw(seam, framebuffer, size); + program.Use(); + program.BindTexture2D("source", texture, 0); + seam.DrawFullscreenTriangle(); + program.Stop(); + seam.Present(); + + byte[] pixel = ReadCentre(seam, framebuffer, size, openFrame: false); + _output.WriteLine($"centre RGBA = {pixel[0]}, {pixel[1]}, {pixel[2]}, {pixel[3]}"); + Assert.Equal(new byte[] { 10, 200, 90, 255 }, pixel); + + program.Dispose(); + GpuTest.AssertClean(seam); + } +} diff --git a/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs b/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs new file mode 100644 index 00000000..da786526 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PlatformSubstitutionTests.cs @@ -0,0 +1,166 @@ +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 1: replaces +/// ClientPlatformWindows on the Vulkan path. These run against the donor lib the +/// renderer compiles against, which is what the Cecil patch transplants into vanilla. +/// +public class PlatformSubstitutionTests +{ + private readonly ITestOutputHelper _output; + + public PlatformSubstitutionTests(ITestOutputHelper output) => _output = output; + + [Fact] + public void ThePlatformDerivesFromClientPlatformWindows() + { + Assert.True(typeof(VulkanClientPlatform).IsSubclassOf(typeof(ClientPlatformWindows))); + Assert.False(typeof(ClientPlatformWindows).IsSealed); + } + + [Fact] + public void TheSelfCheckPassesAgainstTheDonor() + { + bool ok = VulkanClientPlatform.VerifyHost(typeof(ClientPlatformAbstract), typeof(ClientPlatformWindows), out string? reason); + + Assert.True(ok, reason); + Assert.Null(reason); + } + + private abstract class UnpatchedAbstract + { + } + + private class UnpatchedWindows : UnpatchedAbstract + { + } + + private sealed class SealedWindows : UnpatchedAbstract + { + } + + [Fact] + public void TheSelfCheckRejectsALibWithoutTheVirtuals() + { + Assert.False(VulkanClientPlatform.VerifyHost(typeof(UnpatchedAbstract), typeof(UnpatchedWindows), out string? missing)); + Assert.Contains("InitializeGraphics", missing); + + Assert.False(VulkanClientPlatform.VerifyHost(typeof(UnpatchedAbstract), typeof(SealedWindows), out string? sealedReason)); + Assert.Contains("sealed", sealedReason); + } + + [Fact] + public void ThePlatformConstructsHeadless() + { + // The base constructor touches no window or GL state; a null logger takes + // its NullLogger branch, which skips the native platform interface. + var platform = new VulkanClientPlatform(null!); + + Assert.IsAssignableFrom(platform); + Assert.NotNull(platform.Logger); + } + + [Fact] + public void AForcedInstallFailureReturnsFalseWithTheReason() + { + var platform = new VulkanClientPlatform(null!); + int created = 0; + platform.DeviceFactory = () => + { + created++; + return GpuTest.NewDevice(); + }; + + string? previous = Environment.GetEnvironmentVariable(VulkanClientPlatform.ForceInstallFailureVariable); + Environment.SetEnvironmentVariable(VulkanClientPlatform.ForceInstallFailureVariable, "1"); + try + { + bool installed = platform.InitializeGraphics(IntPtr.Zero, 0, 0, out string reason); + + Assert.False(installed); + Assert.Equal("forced by OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE", reason); + Assert.Equal(0, created); + Assert.Null(platform.GraphicsDevice); + } + finally + { + Environment.SetEnvironmentVariable(VulkanClientPlatform.ForceInstallFailureVariable, previous); + } + } + + /// + /// The install path end to end: the platform brings a validated device up, + /// publishes it, writes the crash marker, the published device renders a known + /// colour that reads back, and ShutdownGraphics retires device and marker. + /// + [SkippableFact] + public unsafe void InitializeGraphicsPublishesARenderingDeviceAndShutdownRetiresIt() + { + string dataPath = Path.Combine(Path.GetTempPath(), "optimum-platform-test-" + Guid.NewGuid().ToString("N")); + string marker = Path.Combine(dataPath, ".optimum", "vulkan-session.lock"); + 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."); + + 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"); + + 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.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, 1, 1); + seam.ClearColor(0, 1f, 0.5f, 0f, 1f); + seam.Present(); + var pixel = new byte[4]; + fixed (byte* destination = pixel) + seam.ReadDefaultFramebuffer(0, 0, 1, 1, (IntPtr)destination); + + // Red first, as PacingStatsTests reads the same clear back; 0.5 may + // legally quantise to either 127 or 128 in UNORM8. + Assert.Equal(255, pixel[0]); + Assert.InRange(pixel[1], 127, 128); + Assert.Equal(0, pixel[2]); + Assert.Equal(255, pixel[3]); + GpuTest.AssertClean(seam); + + platform.ShutdownGraphics(); + Assert.Null(platform.GraphicsDevice); + Assert.Equal(EnumRenderBackend.OpenGL, OptimumRender.ActiveBackend); + Assert.False(File.Exists(marker), "a clean shutdown clears the crash marker"); + } + finally + { + platform.ShutdownGraphics(); + try + { + Directory.Delete(dataPath, true); + } + catch (DirectoryNotFoundException) + { + } + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs b/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs new file mode 100644 index 00000000..fd99a0f0 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/PoisonModeTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// OPTIMUM_VULKAN_POISON: fresh images and host-visible buffers carry a loud +/// value until something writes them, so content read before it was written +/// shows up as magenta, NaN or 0xDEADBEEF instead of whatever the allocator +/// held. +/// +public class PoisonModeTests +{ + private readonly ITestOutputHelper _output; + + public PoisonModeTests(ITestOutputHelper output) => _output = output; + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData("0", false)] + [InlineData("1", true)] + [InlineData(" 1 ", true)] + public void TheVariableTurnsPoisonOnForAnyValueButEmptyAndZero(string? setting, bool expected) + { + Assert.Equal(expected, VulkanContext.PoisonRequested(setting)); + } + + [Fact] + public unsafe void HostMemoryIsFilledWithDeadBeefWordsIncludingAPartialTail() + { + var bytes = new byte[11]; + fixed (byte* data = bytes) + { + VulkanPoison.FillHostMemory((IntPtr)data, (ulong)bytes.Length); + } + Assert.Equal(new byte[] { 0xEF, 0xBE, 0xAD, 0xDE, 0xEF, 0xBE, 0xAD, 0xDE, 0xEF, 0xBE, 0xAD }, bytes); + } + + [Theory] + [InlineData(Format.R8G8B8A8Unorm, false, false)] + [InlineData(Format.R8G8B8A8Srgb, false, false)] + [InlineData(Format.R16G16B16A16Sfloat, true, false)] + [InlineData(Format.B10G11R11UfloatPack32, true, false)] + [InlineData(Format.R32Uint, false, true)] + [InlineData(Format.R16Sint, false, true)] + public void FormatsAreClassifiedByComponentType(Format format, bool isFloat, bool isInteger) + { + Assert.Equal(isFloat, VulkanPoison.IsFloat(format)); + Assert.Equal(isInteger, VulkanPoison.IsInteger(format)); + } + + private bool TryCreateContext(bool poison, List messages, out VulkanContext? context) + { + VulkanContextOptions options = GpuTest.ContextOptions(messages); + options.Poison = poison; + bool created = VulkanContext.TryCreate(options, out context, out string? failureReason); + if (!created) _output.WriteLine("Vulkan unavailable: " + failureReason); + return created; + } + + /// + /// A target with every kind of attachment, opened and closed with no clear + /// and no draw, then read back: each attachment holds its format's poison. + /// + [SkippableFact] + public void ARenderTargetNeverClearedOrDrawnReadsThePoisonValue() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(true, messages, out VulkanContext? context), "No usable Vulkan device."); + + using (context) + { + Assert.True(context!.PoisonFreshResources); + const uint size = 8; + using var commands = new SetupQueue(context); + using var textures = new TextureManager(context, commands.Uploads); + 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); + int half = textures.Create(size, size, Format.R16G16B16A16Sfloat); + int single = textures.Create(size, size, Format.R32Sfloat); + int integer = textures.Create(size, size, Format.R32Uint); + int depth = textures.Create(size, size, Format.D32Sfloat); + + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, unorm); + targets.Attach(framebuffer, 1, srgb); + targets.Attach(framebuffer, 2, half); + targets.Attach(framebuffer, 3, single); + targets.Attach(framebuffer, 4, integer); + targets.Attach(framebuffer, -1, depth); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + targets.EnsureRendering(commandBuffer); + targets.EndRendering(commandBuffer); + }); + + byte[] unormBytes = Read(context, commands, textures, unorm, size, 4, ImageAspectFlags.ColorBit); + byte[] srgbBytes = Read(context, commands, textures, srgb, size, 4, ImageAspectFlags.ColorBit); + for (int i = 0; i < unormBytes.Length; i += 4) + { + Assert.Equal(new byte[] { 255, 0, 255, 255 }, unormBytes[i..(i + 4)]); + Assert.Equal(new byte[] { 255, 0, 255, 255 }, srgbBytes[i..(i + 4)]); + } + + byte[] halfBytes = Read(context, commands, textures, half, size, 8, ImageAspectFlags.ColorBit); + for (int i = 0; i < halfBytes.Length; i += 2) + { + Assert.True(Half.IsNaN(BitConverter.ToHalf(halfBytes, i)), "half texel byte " + i + " is not NaN"); + } + + byte[] singleBytes = Read(context, commands, textures, single, size, 4, ImageAspectFlags.ColorBit); + for (int i = 0; i < singleBytes.Length; i += 4) + { + Assert.True(float.IsNaN(BitConverter.ToSingle(singleBytes, i)), "R32F texel byte " + i + " is not NaN"); + } + + byte[] integerBytes = Read(context, commands, textures, integer, size, 4, ImageAspectFlags.ColorBit); + for (int i = 0; i < integerBytes.Length; i += 4) + { + Assert.Equal(VulkanPoison.Word, BitConverter.ToUInt32(integerBytes, i)); + } + + byte[] depthBytes = Read(context, commands, textures, depth, size, 4, ImageAspectFlags.DepthBit); + for (int i = 0; i < depthBytes.Length; i += 4) + { + Assert.Equal(VulkanPoison.Depth, BitConverter.ToSingle(depthBytes, i)); + } + + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + } + + /// Poison is only what a fresh resource starts with: a clear replaces it completely. + [SkippableFact] + public void AClearedTargetReadsItsClearValueWithPoisonOn() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(true, 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 PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + + int color = textures.Create(size, size, Format.R8G8B8A8Unorm); + int depth = textures.Create(size, size, Format.D32Sfloat); + int framebuffer = targets.Create(size, size); + targets.Attach(framebuffer, 0, color); + targets.Attach(framebuffer, -1, depth); + + commands.SubmitAndWait(commandBuffer => + { + targets.Bind(commandBuffer, framebuffer); + // No channel on x.5 in UNORM8 (0.5 may legally read 127 or 128). + targets.ClearColor(commandBuffer, 0, 0.25f, 0.2f, 0.75f, 1f); + targets.ClearDepth(commandBuffer, 1f); + targets.EndRendering(commandBuffer); + }); + + byte[] colorBytes = Read(context!, commands, textures, color, size, 4, ImageAspectFlags.ColorBit); + for (int i = 0; i < colorBytes.Length; i += 4) + { + Assert.Equal(new byte[] { 64, 51, 191, 255 }, colorBytes[i..(i + 4)]); + } + + byte[] depthBytes = Read(context!, commands, textures, depth, size, 4, ImageAspectFlags.DepthBit); + for (int i = 0; i < depthBytes.Length; i += 4) + { + Assert.Equal(1f, BitConverter.ToSingle(depthBytes, i)); + } + + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + } + + [SkippableFact] + public void FreshHostVisibleBuffersHoldDeadBeefOnlyInPoisonMode() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(true, messages, out VulkanContext? poisoned), "No usable Vulkan device."); + using (poisoned) + { + using var buffer = new VulkanBuffer(poisoned!, 64, BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + var bytes = new byte[64]; + Marshal.Copy(buffer.Mapped, bytes, 0, bytes.Length); + for (int i = 0; i < bytes.Length; i += 4) + { + Assert.Equal(VulkanPoison.Word, BitConverter.ToUInt32(bytes, i)); + } + ValidationAssert.NoErrors(messages); + ValidationAssert.NoSyncHazards(messages); + } + + var plainMessages = new List(); + Skip.IfNot(TryCreateContext(false, plainMessages, out VulkanContext? plain), "No usable Vulkan device."); + using (plain) + { + Assert.False(plain!.PoisonFreshResources); + } + } + + private static unsafe byte[] Read( + VulkanContext context, SetupQueue commands, TextureManager textures, + int textureId, uint size, int bytesPerTexel, ImageAspectFlags aspect) + { + VulkanTexture texture = textures.Get(textureId)!; + ulong bytes = (ulong)size * size * (ulong)bytesPerTexel; + + 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(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; + } +} diff --git a/Optimum.Render.Vulkan.Tests/PresentDecouplingTests.cs b/Optimum.Render.Vulkan.Tests/PresentDecouplingTests.cs new file mode 100644 index 00000000..5f4644d7 --- /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) + { + VulkanDevice 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/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/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/QueryRingTests.cs b/Optimum.Render.Vulkan.Tests/QueryRingTests.cs new file mode 100644 index 00000000..fa15f8a8 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/QueryRingTests.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +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 2: occlusion queries through the per-slot query ring. A result +/// appears a frame or two after its query, like GL's availability polling, and +/// nothing waits for it: no mid-frame flush, no query wait, no extra submit. +/// +public class QueryRingTests +{ + private const int Size = 8; + + 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 WhiteFragment = """ + #version 330 core + out vec4 outColor; + void main(void) { outColor = vec4(1.0); } + """; + + /// Sites nothing in a query loop may wait at. + private static readonly WaitSite[] SilentSites = + { + WaitSite.UploadSubmit, WaitSite.FlushFrame, WaitSite.DeviceWaitIdle, WaitSite.Readback, + WaitSite.OcclusionQuery, WaitSite.SwapchainAcquire, WaitSite.Present, + }; + + private readonly ITestOutputHelper _output; + + public QueryRingTests(ITestOutputHelper output) => _output = output; + + private static long[] SilentCounts() + { + var counts = new long[SilentSites.Length]; + for (int i = 0; i < SilentSites.Length; i++) counts[i] = VulkanStats.WaitCount(SilentSites[i]); + return counts; + } + + private static void AssertNoSilentWaits(long[] before) + { + long[] after = SilentCounts(); + for (int i = 0; i < SilentSites.Length; i++) + { + Assert.True(after[i] == before[i], + VulkanStats.WaitSiteTokens[(int)SilentSites[i]] + ": " + (after[i] - before[i]) + " waits, expected 0"); + } + } + + private static int CreateTarget(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 framebuffer; + } + + /// + /// SystemRenderSunMoon's probe: colour writes off, a query around one draw + /// covering squared pixels. + /// + private static void Probe(VulkanDevice seam, int framebuffer, int program, int query, int coverage) + { + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetViewport(0, 0, coverage, coverage); + seam.SetColorMask(false, false, false, false); + seam.BeginOcclusionQuery(query); + seam.DrawFullscreenTriangle(); + seam.EndOcclusionQuery(query); + seam.SetColorMask(true, true, true, true); + } + + /// + /// Sixty presented frames of the sun glare pattern: poll availability, read + /// the result when it is there, begin the next query only then. Every result + /// matches the coverage of the query that produced it (alternating 64 and 16 + /// samples, so a result read from the wrong slot or frame fails), arrives one + /// or two frames after its query and never in the frame that recorded it, and + /// the loop waits nowhere but the one pacing wait per frame, with exactly one + /// submit per frame. + /// + [SkippableFact] + public void SunGlarePatternReadsEveryResultAFrameOrTwoLaterWithoutWaiting() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, WhiteFragment, "query-probe"); + int framebuffer = CreateTarget(seam); + int query = seam.CreateOcclusionQuery(); + bool precise = device!.PreciseOcclusionForTests; + + seam.BeginFrame(); + seam.Present(); + + long[] silentBefore = SilentCounts(); + long pacingBefore = VulkanStats.WaitCount(WaitSite.FramePacing); + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); + ulong signalledBefore = device.TimelineForTests.FrameSignalled; + + const int frames = 60; + bool querying = false; + int begunAt = -1; + int expected = 0; + var latencies = new List(); + for (int frame = 0; frame < frames; frame++) + { + seam.BeginFrame(); + if (querying && seam.IsQueryResultAvailable(query)) + { + int samples = seam.GetQueryResult(query); + if (precise) Assert.Equal(expected, samples); + else Assert.True(samples > 0, "an imprecise query still reports passing samples"); + latencies.Add(frame - begunAt); + querying = false; + } + + if (!querying) + { + int coverage = latencies.Count % 2 == 0 ? Size : Size / 2; + Probe(seam, framebuffer, program, query, coverage); + Assert.False(seam.IsQueryResultAvailable(query), + "a query cannot be available in the frame that recorded it"); + expected = coverage * coverage; + begunAt = frame; + querying = true; + } + seam.Present(); + } + + _output.WriteLine("results " + latencies.Count + ", latencies " + string.Join(",", latencies) + + ", precise " + precise); + + AssertNoSilentWaits(silentBefore); + Assert.Equal(frames, VulkanStats.WaitCount(WaitSite.FramePacing) - pacingBefore); + Assert.Equal(frames, VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore); + Assert.Equal((ulong)frames, device.TimelineForTests.FrameSignalled - signalledBefore); + + // Two frames in flight: frame f+2 reuses f's slot and starts only once + // f finished, so no result takes longer than two frames and a cycle + // (query to next query) lasts at most two. + Assert.True(latencies.Count >= frames / 2 - 1, "only " + latencies.Count + " results in " + frames + " frames"); + foreach (int latency in latencies) Assert.InRange(latency, 1, 2); + + GpuTest.AssertClean(seam); + } + } + + /// + /// Forty queries in one frame, more than one pool holds, polled only after + /// their slot was recycled twice: every result was moved to the host before + /// the reset and matches its own coverage. The same query objects then run a + /// second round with different coverage and report the new counts, and the + /// pools were reused rather than recreated. + /// + [SkippableFact] + public void ResultsSurviveTheirSlotBeingRecycledAndSpanSeveralPools() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + int program = VulkanDeviceIntegrationTests.LinkProgram(seam, FullscreenVertex, WhiteFragment, "query-probe"); + int framebuffer = CreateTarget(seam); + bool precise = device!.PreciseOcclusionForTests; + + const int count = 40; + var queries = new int[count]; + for (int i = 0; i < count; i++) queries[i] = seam.CreateOcclusionQuery(); + + long[] silentBefore = SilentCounts(); + + for (int round = 0; round < 2; round++) + { + seam.BeginFrame(); + for (int i = 0; i < count; i++) Probe(seam, framebuffer, program, queries[i], Coverage(round, i)); + seam.Present(); + + for (int frame = 0; frame < 4; frame++) + { + seam.BeginFrame(); + seam.Present(); + } + + for (int i = 0; i < count; i++) + { + Assert.True(seam.IsQueryResultAvailable(queries[i]), "query " + i + " of round " + round); + int samples = seam.GetQueryResult(queries[i]); + int coverage = Coverage(round, i); + if (precise) Assert.Equal(coverage * coverage, samples); + else Assert.True(samples > 0); + } + } + + AssertNoSilentWaits(silentBefore); + // Two pools in the slot that ran the rounds (round two lands in the + // other slot only if the frame parity says so): never more than two + // per slot. + int pools = device.OcclusionQueryPoolsForTests; + Assert.InRange(pools, 2, 4); + + foreach (int query in queries) seam.DeleteQuery(query); + Assert.False(seam.IsQueryResultAvailable(queries[0])); + + GpuTest.AssertClean(seam); + } + } + + /// + /// 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) + { + VulkanDevice 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 new file mode 100644 index 00000000..4445feef --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ReadbackMidFrameTests.cs @@ -0,0 +1,433 @@ +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 2: readback inside a frame through the ReadbackManager and +/// FrameRing.SubmitPartial. The frame's recorded part is submitted, the copy is +/// waited for on its one timeline value, and the frame carries on in the same +/// slot: no flush into the next slot, no device-idle wait, no frame counter bump. +/// +public class ReadbackMidFrameTests +{ + private readonly ITestOutputHelper _output; + + public ReadbackMidFrameTests(ITestOutputHelper output) => _output = output; + + private static readonly WaitSite[] NeverSites = + { + WaitSite.FlushFrame, WaitSite.DeviceWaitIdle, WaitSite.OcclusionQuery, + }; + + private static long[] Counts(WaitSite[] sites) + { + var counts = new long[sites.Length]; + for (int i = 0; i < sites.Length; i++) counts[i] = VulkanStats.WaitCount(sites[i]); + return counts; + } + + private static void AssertUnchanged(WaitSite[] sites, long[] before) + { + long[] after = Counts(sites); + for (int i = 0; i < sites.Length; i++) + { + Assert.True(after[i] == before[i], + VulkanStats.WaitSiteTokens[(int)sites[i]] + ": " + (after[i] - before[i]) + " waits, expected 0"); + } + } + + private static int CreateTarget(VulkanDevice 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. Only meaningful inside a frame for more than one + /// target: between frames BindFramebuffer is a no-op and the read sees + /// whichever target the last frame bound. + /// + private static byte[] Read(VulkanDevice seam, int framebuffer, int width, int height) + { + var pixels = new byte[width * height * 4]; + unsafe + { + fixed (byte* destination = pixels) + { + seam.BindFramebuffer(framebuffer); + seam.ReadDefaultFramebuffer(0, 0, width, height, (IntPtr)destination); + } + } + return pixels; + } + + 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); + } + } + + 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) + { + seam.UpdateUniformBuffer(ubo, (IntPtr)values, 0, sizeof(float) * 4); + } + } + + /// + /// Draw into A, read A mid-frame, then draw into B with the same uniform block + /// (its ring snapshot was taken before the partial submit and is reused after + /// it), change the block and draw into A again, present. The mid-frame read + /// sees the first draw; after present A holds the second tint and B the first. + /// The frame submitted twice under two timeline values from one pacing wait, + /// the read waited once at the readback site, and nothing flushed or waited + /// for the device. + /// + [SkippableFact] + public void AReadbackMidFrameSeesEarlierDrawsAndLaterDrawsStillReachTheFrame() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 16; + + int program = VulkanDeviceIntegrationTests.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; } + """, "readback-tint"); + + int targetA = CreateTarget(seam, size, out _); + int targetB = CreateTarget(seam, size, out _); + int ubo = seam.CreateUniformBuffer(program, 0, "Tint", sizeof(float) * 4); + SetTint(seam, ubo, 60, 120, 180); + seam.BindUniformBuffer(ubo); + + seam.BeginFrame(); + seam.Present(); + + long[] neverBefore = Counts(NeverSites); + long uploadsBefore = VulkanStats.WaitCount(WaitSite.UploadSubmit); + long readbacksBefore = VulkanStats.WaitCount(WaitSite.Readback); + long pacingBefore = VulkanStats.WaitCount(WaitSite.FramePacing); + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); + ulong signalledBefore = device!.TimelineForTests.FrameSignalled; + + seam.BeginFrame(); + seam.UseProgram(program); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.SetViewport(0, 0, size, size); + + seam.BindFramebuffer(targetA); + seam.DrawFullscreenTriangle(); + byte[] midFrame = Read(seam, targetA, size, size); + long readbacksMid = VulkanStats.WaitCount(WaitSite.Readback) - readbacksBefore; + + seam.BindFramebuffer(targetB); + seam.DrawFullscreenTriangle(); + + SetTint(seam, ubo, 200, 40, 20); + seam.BindFramebuffer(targetA); + seam.DrawFullscreenTriangle(); + seam.Present(); + + long submits = VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore; + ulong signalled = device.TimelineForTests.FrameSignalled - signalledBefore; + long pacing = VulkanStats.WaitCount(WaitSite.FramePacing) - pacingBefore; + + AssertEvery(midFrame, 60, 120, 180, 255, "mid-frame read of A"); + Assert.Equal(1, readbacksMid); + Assert.Equal(2, submits); + Assert.Equal(2UL, signalled); + Assert.Equal(1, pacing); + Assert.Equal(uploadsBefore, VulkanStats.WaitCount(WaitSite.UploadSubmit)); + + // Binding is a no-op between frames, so both targets are read at the + // start of the next frame (attachments load what the last frame stored). + seam.BeginFrame(); + AssertEvery(Read(seam, targetA, size, size), 200, 40, 20, 255, "A after present"); + AssertEvery(Read(seam, targetB, size, size), 60, 120, 180, 255, "B after present"); + seam.Present(); + AssertUnchanged(NeverSites, neverBefore); + + GpuTest.AssertClean(seam); + } + } + + /// + /// Twelve presented frames, each clearing a target three times with two + /// readbacks in between. Every read sees its own frame's latest clear, the + /// last clear of each frame survives to the next frame's start, and the + /// timeline counts three submissions per frame from one pacing wait each: + /// slots rotate normally even though every frame submits in parts. + /// + [SkippableFact] + public void ReadbacksInConsecutiveFramesPaceOncePerFrameAndReadTheirOwnFrame() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 4; + const int frames = 12; + int target = CreateTarget(seam, size, out _); + + seam.BeginFrame(); + seam.Present(); + + long[] neverBefore = Counts(NeverSites); + long readbacksBefore = VulkanStats.WaitCount(WaitSite.Readback); + long pacingBefore = VulkanStats.WaitCount(WaitSite.FramePacing); + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); + ulong signalledBefore = device!.TimelineForTests.FrameSignalled; + + for (int frame = 0; frame < frames; frame++) + { + seam.BeginFrame(); + if (frame > 0) + { + // Nothing cleared yet this frame: the previous frame's last clear. + byte[] carried = Read(seam, target, 1, 1); + Assert.Equal(new byte[] { (byte)(frame - 1 + 200), 51, 64, 255 }, carried); + } + else + { + seam.BindFramebuffer(target); + seam.ClearColor(0, 0f, 0.2f, 0.25f, 1f); + Read(seam, target, 1, 1); + } + + seam.BindFramebuffer(target); + seam.ClearColor(0, (frame + 100) / 255f, 0.2f, 0.25f, 1f); + AssertEvery(Read(seam, target, size, size), (byte)(frame + 100), 51, 64, 255, "frame " + frame); + + seam.BindFramebuffer(target); + seam.ClearColor(0, (frame + 200) / 255f, 0.2f, 0.25f, 1f); + seam.Present(); + } + + Assert.Equal(frames, VulkanStats.WaitCount(WaitSite.FramePacing) - pacingBefore); + Assert.Equal(2 * frames, VulkanStats.WaitCount(WaitSite.Readback) - readbacksBefore); + Assert.Equal(3 * frames, VulkanStats.WaitCount(WaitSite.QueueSubmit) - submitsBefore); + Assert.Equal((ulong)(3 * frames), device.TimelineForTests.FrameSignalled - signalledBefore); + AssertUnchanged(NeverSites, neverBefore); + + AssertEvery(Read(seam, target, size, size), frames - 1 + 200, 51, 64, 255, "after the loop"); + GpuTest.AssertClean(seam); + } + } + + /// + /// Two 4 MiB readbacks in one frame outgrow the 1 MiB arena twice; the second + /// growth retires an arena a submitted copy wrote into. Both reads are exact, + /// and retiring through the timeline keeps validation clean. + /// + [SkippableFact] + public void ReadbacksLargerThanTheArenaGrowItAndStayExact() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 1024; + Assert.True((ulong)size * size * 4 > ReadbackManager.MinimumArenaSize); + int target = CreateTarget(seam, size, out _); + + seam.BeginFrame(); + seam.BindFramebuffer(target); + seam.ClearColor(0, 10 / 255f, 20 / 255f, 30 / 255f, 1f); + AssertEvery(Read(seam, target, size, size), 10, 20, 30, 255, "first"); + seam.BindFramebuffer(target); + seam.ClearColor(0, 40 / 255f, 50 / 255f, 60 / 255f, 1f); + AssertEvery(Read(seam, target, size, size), 40, 50, 60, 255, "second"); + seam.Present(); + + for (int frame = 0; frame < 3; frame++) + { + seam.BeginFrame(); + seam.Present(); + } + + GpuTest.AssertClean(seam); + } + } + + /// + /// 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) + { + 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; + 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 (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 AnUploadInsideAFrameRidesTheFramesOneSubmission() + { + Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 4; + int targetA = CreateTarget(seam, size, out _); + int targetB = CreateTarget(seam, size, out _); + int uploaded = CreateTarget(seam, size, out int texture); + + var data = new byte[size * size * 4]; + for (int i = 0; i < size * size; i++) + { + data[i * 4] = (byte)(i * 16); + data[i * 4 + 1] = 77; + data[i * 4 + 2] = 99; + data[i * 4 + 3] = 255; + } + + seam.BeginFrame(); + seam.Present(); + + long[] neverBefore = Counts(NeverSites); + long submitsBefore = VulkanStats.WaitCount(WaitSite.QueueSubmit); + + seam.BeginFrame(); + seam.BindFramebuffer(targetA); + seam.ClearColor(0, 1f, 0f, 0f, 1f); + fixed (byte* pixels = data) + seam.UploadTexture2D(texture, 0, 0, 0, size, size, EnumTexturePixelFormat.Rgba, (IntPtr)pixels); + seam.BindFramebuffer(targetB); + seam.ClearColor(0, 0f, 0f, 1f, 1f); + seam.Present(); + + 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. + seam.BeginFrame(); + AssertEvery(Read(seam, targetA, size, size), 255, 0, 0, 255, "A"); + AssertEvery(Read(seam, targetB, size, size), 0, 0, 255, 255, "B"); + Assert.Equal(data, Read(seam, uploaded, size, size)); + seam.Present(); + AssertUnchanged(NeverSites, neverBefore); + + GpuTest.AssertClean(seam); + } + } + + /// + /// The ring itself: a partial submit signals the slot's current value, stays + /// in the slot with its uniform cursor, and records on under a fresh value; + /// the next frame in that slot starts only after the frame's last value. + /// + [SkippableFact] + public void APartialSubmitStaysInItsSlotAndTheSlotsNextFrameWaitsForItsLastValue() + { + Skip.IfNot(GpuTest.TryCreateContext(_output, null, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + using var ring = new FrameRing(context!, framesInFlight: 2, uniformRingSize: 1 << 20); + + FrameSlot first = ring.BeginFrame(); + Assert.Equal(1UL, first.FrameValue); + Assert.True(first.TryAllocateUniforms(100, out _)); + ulong used = first.UniformBytesUsed; + CommandBufferHandle before = new(first.CommandBuffer.Handle); + + Assert.Equal(1UL, ring.SubmitPartial()); + Assert.Same(first, ring.Current); + Assert.Equal(2UL, first.FrameValue); + Assert.Equal(1UL, ring.Timeline.FrameSignalled); + Assert.Equal(1, first.PartialSubmits); + Assert.Equal(used, first.UniformBytesUsed); + Assert.NotEqual(before.Value, first.CommandBuffer.Handle); + ring.EndFrame(); + Assert.Equal(2UL, first.LastSignalledValue); + + FrameSlot second = ring.BeginFrame(); + Assert.NotSame(first, second); + Assert.Equal(3UL, second.FrameValue); + ring.EndFrame(); + + FrameSlot again = ring.BeginFrame(); + Assert.Same(first, again); + Assert.True(ring.Timeline.FrameCompleted >= 2UL, "the slot restarted before its partial frame finished"); + Assert.Equal(4UL, again.FrameValue); + Assert.Equal(0, again.PartialSubmits); + Assert.Equal(0UL, again.UniformBytesUsed); + ring.EndFrame(); + + VulkanStats.WaitDeviceIdle(context!.Api, context.Device); + } + } + + private readonly record struct CommandBufferHandle(nint Value); +} 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.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.Tests/RenderTargetTests.cs b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs new file mode 100644 index 00000000..327a7bdd --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/RenderTargetTests.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +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; +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. 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 +{ + private readonly ITestOutputHelper _output; + + public RenderTargetTests(ITestOutputHelper output) => _output = output; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) => + GpuTest.TryCreateContext(output, messages, out context); + + 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. The pipeline masks off the + /// attachment the program never writes, so attachment 1 must come through untouched. + /// + [SkippableFact] + public unsafe void AnAttachmentTheProgramDoesNotWriteKeepsItsContents() + { + 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + 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)); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + 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 + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// + /// 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 void ASlotTheDeclaredPassLeavesOutIsUndefinedInTheFormats() + { + 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); + using var targets = new RenderTargetManager(context!, textures); + + int framebuffer = targets.Create(8, 8); + for (int i = 0; i < 4; i++) + { + targets.Attach(framebuffer, i, textures.Create(8, 8, Format.R8G8B8A8Unorm)); + } + + VulkanFramebuffer bound = targets.Get(framebuffer)!; + RenderTargetFormats all = targets.FormatsOf(targets.FormatsIdOf(bound)); + Assert.Equal(4, all.ColorFormats.Length); + Assert.All(all.ColorFormats, format => Assert.Equal(Format.R8G8B8A8Unorm, format)); + + commands.SubmitAndWait(commandBuffer => + { + targets.DeclarePass(commandBuffer, new PassDeclaration { Name = "Compose", ColorSlots = ~(1u << 2) }, framebuffer); + }); + 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]); + Assert.Equal(Format.R8G8B8A8Unorm, excluded.ColorFormats[3]); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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, SetupQueue commands, RenderTargetManager targets, + GraphicsPipelineCache pipelines, PipelineKeyState state, ShaderProgramResources program, + int framebuffer, uint size) + { + VulkanFramebuffer bound = targets.Get(framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = targets.FormatsOf(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, PipelineKeyState.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, SetupQueue 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, SetupQueue 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/SceneSsaoTests.cs b/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs new file mode 100644 index 00000000..9e097445 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs @@ -0,0 +1,214 @@ +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); + } + } + + /// + /// 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/SetConventionTests.cs b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs new file mode 100644 index 00000000..5dd28f95 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SetConventionTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +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; + +/// +/// 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, + ["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; + 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(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. + /// + [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.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/ShaderCacheTests.cs b/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs new file mode 100644 index 00000000..da94082a --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderCacheTests.cs @@ -0,0 +1,439 @@ +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; + +/// +/// 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)); + } + + // ------------------------------------------------------- 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.Tests/ShaderCorpus.cs b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs new file mode 100644 index 00000000..269bd314 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderCorpus.cs @@ -0,0 +1,361 @@ +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; + } + + /// + /// 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) + { + string directory = Path.Combine(AssetRoot, "shaderincludes"); + if (Directory.Exists(directory)) + { + foreach (string path in Directory.EnumerateFiles(directory)) + { + includes[Path.GetFileName(path)] = File.ReadAllText(path); + } + } + } + + string overlays = Path.Combine(RepositoryRoot, "sources", "shaderincludes"); + if (Directory.Exists(overlays)) + { + foreach (string path in Directory.EnumerateFiles(overlays)) + { + 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; + /// 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; + /// 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, + /// 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; + } + + /// + /// 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, + }; + // 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, + }; + // 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). + // 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", + TaaMotion = 1, TaaMotionLocation = 2, + WavingStuff = 0, FoamEffect = 0, ShinyEffect = 0, + }; + } + + /// + /// 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"); + lines.Add($"#define TAAMOTION {variant.TaaMotion}"); + lines.Add($"#define TAAMOTIONLOCATION {variant.TaaMotionLocation}"); + lines.Add($"#define OPTIMUMAO {variant.OptimumAo}"); + } + 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}"); + 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"; + // 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. + 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..22d2ad31 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationTests.cs @@ -0,0 +1,361 @@ +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()); + } + } + + /// + /// 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", + })); + // 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(); + 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); + + 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 = 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() + { + 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 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(SetConvention.FaceDataBinding, faceData!.Binding); + } + } + } + + /// + /// 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..75b5fce3 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ShaderTranslationUnitTests.cs @@ -0,0 +1,835 @@ +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; + +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. 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 DeclaredStorageBuffersMoveToTheFaceDataBindingThroughMemoryQualifiers() + { + 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(SetConvention.FaceDataBinding, block.Binding); + Assert.Equal(SetConvention.StorageSet, block.Set); + } + + [Fact] + public void SamplersBecomeSlotsOrFrameTexturesRatherThanBlockMembers() + { + 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"].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); + } + + // ----------------------------------------------------------------- rewriting + + 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 = 4) out; + uniform bool visible; + void main() { + for (int i = 0; i < 3; i++) gl_Position = gl_in[i].gl_Position, EmitVertex(); + if (visible) EmitVertex (); else EndPrimitive(); + 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); + + // 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) + { + 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; + } + + /// + /// 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 NamedBlocksTakeTheConventionsSetTwoBindingsWhateverTheShaderStated() + { + ProgramInterfaceLayout layout = LayoutOf((EnumShaderType.VertexShader, """ + #version 330 core + layout(std140, binding = 0) uniform Lights { vec4 pos; }; + 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.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] + 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 = 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. + 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 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 MemberAccessOfAReservedNameMatchesItsDeclaration() + { + 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); + } + + /// + /// "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)); + } + + // ------------------------------------------------------- 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/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/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.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.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.Tests/SwapchainRecreationTests.cs b/Optimum.Render.Vulkan.Tests/SwapchainRecreationTests.cs new file mode 100644 index 00000000..4385a33a --- /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) + { + 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.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..ce75d618 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SwapchainRetirementTests.cs @@ -0,0 +1,314 @@ +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); + } + + /// + /// 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() + { + 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 new file mode 100644 index 00000000..afaa9d12 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SwapchainTests.cs @@ -0,0 +1,315 @@ +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. + /// + internal 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 = GpuTest.NewDevice(); + 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; + _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 = GpuTest.NewDevice(); + 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 = 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 = GpuTest.NewDevice(); + 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 = 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; + } + + internal static int LinkFullscreenProgram(VulkanDevice 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(VulkanDevice device) => GpuTest.AssertClean(device); +} diff --git a/Optimum.Render.Vulkan.Tests/SyncHazardLedgerTests.cs b/Optimum.Render.Vulkan.Tests/SyncHazardLedgerTests.cs new file mode 100644 index 00000000..2e45e671 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SyncHazardLedgerTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +[assembly: TestCollectionOrderer( + "Optimum.Render.Vulkan.Tests.LedgerRunsLastOrderer", "Optimum.Render.Vulkan.Tests")] + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// xunit's default collection order, with the ledger collection moved to the +/// end so it sees every other test's observations. The suite is serial +/// (AssemblyInfo.cs), so "last" is well defined. +/// +public sealed class LedgerRunsLastOrderer : ITestCollectionOrderer +{ + private readonly DefaultTestCollectionOrderer _default; + + public LedgerRunsLastOrderer(IMessageSink diagnosticMessageSink) => + _default = new DefaultTestCollectionOrderer(); + + public IEnumerable OrderTestCollections(IEnumerable testCollections) + { + List ordered = _default.OrderTestCollections(testCollections).ToList(); + List ledger = ordered + .Where(c => c.DisplayName == SyncHazardLedgerTests.CollectionName) + .ToList(); + ordered.RemoveAll(c => c.DisplayName == SyncHazardLedgerTests.CollectionName); + ordered.AddRange(ledger); + return ordered; + } +} + +/// +/// Keeps honest: every entry is well formed +/// and still happens. Pattern: KnownDonorGaps in +/// Optimum.Tests/mod-patcher-manifest-consistency-tests.cs. +/// +[Collection(CollectionName)] +public class SyncHazardLedgerTests +{ + public const string CollectionName = "Synchronization hazard ledger (runs last)"; + + private readonly ITestOutputHelper _output; + + public SyncHazardLedgerTests(ITestOutputHelper output) => _output = output; + + [Fact] + public void EveryPinnedHazardStillOccurs() + { + string summary = SyncHazardLedger.Summary(); + _output.WriteLine("validation features: '" + GpuTest.ValidationFeatures + "'"); + _output.WriteLine(summary); + + string? summaryPath = Environment.GetEnvironmentVariable("OPTIMUM_TEST_VALIDATION_SUMMARY"); + if (!string.IsNullOrEmpty(summaryPath)) System.IO.File.WriteAllText(summaryPath, summary); + + List stale = SyncHazardLedger.StaleEntries(KnownSyncHazards.Entries); + Assert.True(stale.Count == 0, + "These pinned synchronization hazards no longer occur; remove them from KnownSyncHazards:\n " + + string.Join("\n ", stale)); + } + + /// + /// The companion rule on synthetic data: an entry goes stale only when its + /// test asserted and the hazard was absent, never because the test did not run. + /// + [Fact] + public void AnEntryIsStaleOnlyWhenItsTestAssertedWithoutTheHazard() + { + const string testClass = nameof(SyncHazardLedgerTests) + "Synthetic"; + var entries = new[] + { + new KnownSyncHazard("SYNC-HAZARD-WRITE-AFTER-WRITE", testClass, "StillOccurs", "synthetic", "1B"), + new KnownSyncHazard("SYNC-HAZARD-WRITE-AFTER-WRITE", testClass, "Vanished", "synthetic", "1B"), + new KnownSyncHazard("SYNC-HAZARD-WRITE-AFTER-WRITE", testClass, "NeverRan", "synthetic", "2"), + }; + + SyncHazardLedger.Observe(testClass, "StillOccurs", new[] { "SYNC-HAZARD-WRITE-AFTER-WRITE" }); + SyncHazardLedger.Observe(testClass, "Vanished", Array.Empty()); + + Assert.Equal( + new[] { "SYNC-HAZARD-WRITE-AFTER-WRITE | " + testClass + ".Vanished" }, + SyncHazardLedger.StaleEntries(entries)); + } + + /// + /// An unpinned synchronization message fails NoSyncHazards and is left out + /// of NoErrors; a best-practices warning fails neither. + /// + [Fact] + public void AnUnlistedSynchronizationMessageFailsAndABestPracticesWarningDoesNot() + { + var advice = new List { "[warning] [BestPractices-synthetic] advice" }; + ValidationAssert.NoErrors(advice); + ValidationAssert.NoSyncHazards(advice); + + var hazard = new List { "[error] [SYNC-HAZARD-WRITE-AFTER-WRITE] synthetic hazard" }; + ValidationAssert.NoErrors(hazard); + XunitException failure = Assert.ThrowsAny(() => ValidationAssert.NoSyncHazards(hazard)); + Assert.Contains( + "SYNC-HAZARD-WRITE-AFTER-WRITE | SyncHazardLedgerTests | " + + nameof(AnUnlistedSynchronizationMessageFailsAndABestPracticesWarningDoesNot), + failure.Message); + } + + [Fact] + public void EveryPinnedHazardNamesARealTestItsDefectAndTheRetiringPhase() + { + var problems = new List(); + var seen = new HashSet(StringComparer.Ordinal); + Assembly assembly = typeof(SyncHazardLedgerTests).Assembly; + + foreach (KnownSyncHazard entry in KnownSyncHazards.Entries) + { + string key = entry.Id + " | " + entry.TestClass + "." + entry.TestMethod; + if (!seen.Add(key)) problems.Add(key + ": listed twice"); + if (!entry.Id.StartsWith("SYNC-", StringComparison.Ordinal)) problems.Add(key + ": not a SYNC- id"); + if (string.IsNullOrWhiteSpace(entry.Defect)) problems.Add(key + ": no defect named"); + if (entry.RetiredBy is not ("1B" or "2")) problems.Add(key + ": retiring phase must be 1B or 2"); + + Type? type = assembly.GetType("Optimum.Render.Vulkan.Tests." + entry.TestClass); + MethodInfo? method = type?.GetMethod(entry.TestMethod, BindingFlags.Public | BindingFlags.Instance); + if (method == null || method.GetCustomAttributes(typeof(FactAttribute), inherit: true).Length == 0) + { + problems.Add(key + ": no such test"); + } + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + [Theory] + [InlineData("[error] [SYNC-HAZARD-WRITE-AFTER-WRITE] vkQueueSubmit(): ...", "SYNC-HAZARD-WRITE-AFTER-WRITE", true, false)] + [InlineData("[warning] [BestPractices-vkCreateDevice-physical-device-features-not-retrieved] ...", "BestPractices-vkCreateDevice-physical-device-features-not-retrieved", false, true)] + [InlineData("[error] [VUID-vkCmdDraw-None-08600] ...", "VUID-vkCmdDraw-None-08600", false, false)] + [InlineData("[error] no tag here", null, false, false)] + public void MessageIdsComeFromTheBracketTagAfterTheSeverity( + string message, string? id, bool synchronization, bool bestPractices) + { + Assert.Equal(id, ValidationAssert.MessageId(message)); + Assert.Equal(synchronization, ValidationAssert.IsSynchronization(message)); + Assert.Equal(bestPractices, ValidationAssert.IsBestPractices(message)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs b/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs new file mode 100644 index 00000000..ee72a8a1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/SyncValidationControlTests.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +using Xunit; +using Xunit.Abstractions; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The positive control for . A +/// suite with no synchronization messages means nothing unless synchronization +/// validation demonstrably reports, under a SYNC- id our message tag carries, +/// when a hazard is there. This test creates one on purpose. +/// +public class SyncValidationControlTests +{ + private readonly ITestOutputHelper _output; + + public SyncValidationControlTests(ITestOutputHelper output) => _output = output; + + [SkippableFact] + public unsafe void AnUnsynchronisedWriteAfterWriteIsReportedUnderASyncId() + { + var messages = new List(); + VulkanContextOptions options = GpuTest.ContextOptions(messages); + options.ValidationFeatures = "sync"; + Skip.IfNot(VulkanContext.TryCreate(options, out VulkanContext? context, out string? reason), + "No usable Vulkan device: " + reason); + + using (context) + { + Skip.IfNot(context!.ValidationEnabled, "Validation layer not installed."); + const uint size = 16; + 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))!; + + commands.SubmitAndWait(commandBuffer => + { + textures.TransitionTexture(commandBuffer, a, ImageLayout.General); + textures.TransitionTexture(commandBuffer, b, ImageLayout.General); + textures.TransitionTexture(commandBuffer, c, ImageLayout.General); + + var region = new ImageCopy + { + SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + Extent = new Extent3D(size, size, 1), + }; + // Two writes to the same texels of b and a read of them, with no + // barrier in between: the misuse the layer exists to name. + context.Api.CmdCopyImage(commandBuffer, a.Image, ImageLayout.General, b.Image, ImageLayout.General, 1, ®ion); + context.Api.CmdCopyImage(commandBuffer, c.Image, ImageLayout.General, b.Image, ImageLayout.General, 1, ®ion); + context.Api.CmdCopyImage(commandBuffer, b.Image, ImageLayout.General, a.Image, ImageLayout.General, 1, ®ion); + }); + + List snapshot = ValidationAssert.Snapshot(messages); + foreach (string message in snapshot) _output.WriteLine(message); + Assert.True(snapshot.Any(ValidationAssert.IsSynchronization), + "synchronization validation reported nothing for a deliberate hazard:\n" + string.Join("\n", snapshot)); + } + } +} diff --git a/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs new file mode 100644 index 00000000..241f86eb --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaEntityMotionWriterTests.cs @@ -0,0 +1,778 @@ +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, + }; + + /// + /// 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 + + /// + /// 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); + } + } + + /// + /// 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 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; } + } + + /// + /// 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, + float[]? previousView = null, + float[]? previousProjection = null, + float? reactive = null) + { + VulkanDevice 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", previousProjection ?? Identity); + SetMatrix(seam, program, "prevViewMatrix", previousView ?? Identity); + SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); + SetInt(seam, program, "taaHistoryValid", historyValid); + 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); + + 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, + decoded[offset + 3] / 255f); + } + + 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. + 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(VulkanDevice 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-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(VulkanDevice 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(VulkanDevice 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(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(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(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(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( + 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(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(VulkanDevice 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, VulkanDevice 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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs new file mode 100644 index 00000000..b36f54e0 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaInstancedMotionWriterTests.cs @@ -0,0 +1,693 @@ +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, + }; + + /// + /// 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 + + /// + /// 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); + } + } + + /// + /// 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, 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 + { + 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, + float[]? previousView = null, float[]? previousProjection = null) + { + VulkanDevice 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", 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); + + 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(VulkanDevice 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].Reactive; + } + 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(VulkanDevice 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(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(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(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(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( + 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(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(VulkanDevice 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, VulkanDevice 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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TaaLiquidMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs new file mode 100644 index 00000000..6546bd97 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaLiquidMotionTests.cs @@ -0,0 +1,802 @@ +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"); + } + } + + /// + /// 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 + { + 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, + float[]? previousView = null) + { + VulkanDevice 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", previousView ?? 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(VulkanDevice 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(VulkanDevice 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( + VulkanDevice 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(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(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(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(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(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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.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.Tests/TaaMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs new file mode 100644 index 00000000..ad61cd54 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaMotionWriterTests.cs @@ -0,0 +1,585 @@ +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) + { + VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(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(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(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(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(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(VulkanDevice 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, VulkanDevice 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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TaaMoverMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs new file mode 100644 index 00000000..5f545d26 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaMoverMotionTests.cs @@ -0,0 +1,696 @@ +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) + { + VulkanDevice 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); + // 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); + + // 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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(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(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(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(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( + 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(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(VulkanDevice 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, VulkanDevice 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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TaaParticleMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs new file mode 100644 index 00000000..73397823 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaParticleMotionTests.cs @@ -0,0 +1,901 @@ +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) + { + VulkanDevice 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) + { + VulkanDevice 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(VulkanDevice 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( + VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(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(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(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(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(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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TaaResolveTests.cs b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs new file mode 100644 index 00000000..ed416807 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaResolveTests.cs @@ -0,0 +1,1648 @@ +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 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 +/// , 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) => + GpuTest.TryCreateContext(output, messages, out context); + + // ------------------------------------------------------------------ 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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// + /// Sky (depth == 1, nothing wrote the motion attachment) is a direction: a + /// camera translation must not move it. With a real perspective (far 60) + /// and the previous camera 8 blocks to the side, the finite reprojection + /// would slide the history band by 8 / 60 * 32 / tan(35 deg) = 6 px; the + /// infinite-direction path keeps it exactly where it is. The view has the + /// eye 1.7 blocks above the origin, as CameraMatrixOrigin does, so the + /// direction has to be far minus near rather than the far point alone. + /// + [SkippableFact] + public unsafe void SkyDoesNotMoveUnderCameraTranslation() + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + const double near = 0.0689, far = 60.0, fov = 70.0 * Math.PI / 180.0, eyeHeight = 1.7; + double[] projection = Vintagestory.API.MathTools.Mat4d.Perspective(Vintagestory.API.MathTools.Mat4d.Create(), fov, 1.0, near, far); + double[] view = Vintagestory.API.MathTools.Mat4d.Identity(Vintagestory.API.MathTools.Mat4d.Create()); + view = Vintagestory.API.MathTools.Mat4d.Translate(view, view, 0.0, -eyeHeight, 0.0); + double[] viewProj = Vintagestory.API.MathTools.Mat4d.Mul(Vintagestory.API.MathTools.Mat4d.Create(), projection, view); + double[] inverse = Vintagestory.API.MathTools.Mat4d.Invert(Vintagestory.API.MathTools.Mat4d.Create(), viewProj)!; + float[] invF = Array.ConvertAll(inverse, v => (float)v); + float[] vpF = Array.ConvertAll(viewProj, v => (float)v); + float[] viewF = Array.ConvertAll(view, v => (float)v); + const float cameraDeltaX = 8f; + double finiteShift = cameraDeltaX / far * (Size / 2.0) / Math.Tan(fov / 2.0); + + using (context) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using ShaderProgramResources program = LoadProgram(context!, compiler, state); + + var inputs = CreateInputSet(textures); + UploadRgba16F(textures, inputs.SceneTex, (x, y) => ((x + y) % 2 == 0) ? 0.3f : 0.7f, + (x, y) => ((x + y) % 2 == 0) ? 0.3f : 0.7f, (x, y) => ((x + y) % 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); + UploadFlatR32F(textures, inputs.HistoryDepth, (float)far); + + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + var uniforms = new TaaUniforms + { + ResetHistory = 0, + BlendAlpha = 0.05f, + InvViewProjJittered = invF, + PrevViewProj = vpF, + ViewMatrix = viewF, + CameraDelta = new[] { cameraDeltaX, 0f, 0f }, + }; + + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + double centroid = RedCentroidX(colorBytes, stripeStart - 8, stripeStart + stripeWidth + 8, background); + _output.WriteLine($"stripe centroid x = {centroid:F3}, expected {stripeStart + stripeWidth / 2.0:F1}; a finite reprojection would have moved it by {finiteShift:F1} px"); + Assert.True(finiteShift > 3.0, "the translation chosen is too small to tell the two paths apart"); + Assert.InRange(centroid, stripeStart + stripeWidth / 2.0 - 0.35, stripeStart + stripeWidth / 2.0 + 0.35); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// + /// 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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(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, SetupQueue commands, TextureManager textures, PipelineKeyState state, + RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, + SharedLayoutTestBinding 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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using 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); + + ValidationAssert.NoSyncHazards(messages); + } + } + + // ------------------------------- 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); + } + + /// + /// 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(); + 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 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); + 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, PipelineKeyState 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()); + 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 resolve's own sky path had the same trap the sky pass had: it treated + /// the reconstructed far point (in the origin space CameraMatrixOrigin draws + /// in, where the eye sits at LocalEyePos) as the view direction. A still + /// camera above the origin then reprojected every sky pixel by a fixed + /// eye / far * (rows / 2) / tan(fov / 2) pixels - 0.6 px in game - and the + /// history drifted by that much every frame. With a real perspective and a + /// translated view the band has to stay exactly where it is, horizontally + /// and vertically (the in-game error was vertical: the eye offset is on Y). + /// + [SkippableTheory] + [InlineData(1.7)] + [InlineData(6.0)] + public unsafe void SkyStaysPutWhenTheCameraSitsAboveTheOrigin(double eyeHeight) + { + var messages = new List(); + Skip.IfNot(TryCreateContext(_output, messages, out VulkanContext? context), "No usable Vulkan device."); + + const double near = 0.0689, far = 60.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[] view = Vintagestory.API.MathTools.Mat4d.Identity(Vintagestory.API.MathTools.Mat4d.Create()); + view = Vintagestory.API.MathTools.Mat4d.RotateX(view, view, -0.2); + view = Vintagestory.API.MathTools.Mat4d.Translate(view, view, 0.0, -eyeHeight, 0.0); + double[] viewProj = Vintagestory.API.MathTools.Mat4d.Mul(Vintagestory.API.MathTools.Mat4d.Create(), projection, view); + double[] inverse = Vintagestory.API.MathTools.Mat4d.Invert(Vintagestory.API.MathTools.Mat4d.Create(), viewProj)!; + float[] invF = Array.ConvertAll(inverse, v => (float)v); + float[] vpF = Array.ConvertAll(viewProj, v => (float)v); + float[] viewF = Array.ConvertAll(view, v => (float)v); + double predictedBias = eyeHeight / far * (Size / 2.0) / Math.Tan(fov / 2.0); + _output.WriteLine($"eye {eyeHeight}: far-point-as-direction would drift the sky by ~{predictedBias:F2} px per frame"); + + using (context) + using (var commands = new SetupQueue(context!)) + using (var textures = new TextureManager(context!, commands.Uploads)) + { + 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); + using ShaderProgramResources program = LoadProgram(context!, compiler, state); + + var inputs = CreateInputSet(textures); + // A checkered scene keeps the neighbourhood clip box wide (0.3..0.7), + // so the history stripe survives the rectification. + UploadRgba16F(textures, inputs.SceneTex, (x, y) => ((x + y) % 2 == 0) ? 0.3f : 0.7f, + (x, y) => ((x + y) % 2 == 0) ? 0.3f : 0.7f, (x, y) => ((x + y) % 2 == 0) ? 0.3f : 0.7f, (_, _) => 1f); + UploadFlatRgba8(textures, inputs.GlowTex, 0, 0, 0, 255); + 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; + UploadFlatRgba8(textures, inputs.HistoryGlow, 0, 0, 0, 255); + // Linear view depth of the far plane, so the disocclusion test passes. + UploadFlatR32F(textures, inputs.HistoryDepth, (float)far); + TaaAttachmentSet output = CreateAttachmentSet(textures, targets); + var uniforms = new TaaUniforms + { + ResetHistory = 0, + BlendAlpha = 0.05f, + InvViewProjJittered = invF, + PrevViewProj = vpF, + ViewMatrix = viewF, + CameraDelta = new[] { 0f, 0f, 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); + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + byte[] colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + double centroid = RedCentroidX(colorBytes, stripeStart - 8, stripeStart + stripeWidth + 8, background); + _output.WriteLine($"stripe centroid x = {centroid:F3} (expected {stripeStart + stripeWidth / 2.0:F1})"); + Assert.InRange(centroid, stripeStart + stripeWidth / 2.0 - 0.35, stripeStart + stripeWidth / 2.0 + 0.35); + + UploadRgba16F(textures, inputs.HistoryColor, + (_, y) => y is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (_, y) => y is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (_, y) => y is >= stripeStart and < stripeStart + stripeWidth ? stripe : background, + (_, _) => 1f); + ResolveOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + inputs, uniforms, output); + colorBytes = ReadTextureBytes(context!, commands, textures, output.Color, 8); + double centroidY = RedCentroidY(colorBytes, stripeStart - 8, stripeStart + stripeWidth + 8, background); + _output.WriteLine($"stripe centroid y = {centroidY:F3} (expected {stripeStart + stripeWidth / 2.0:F1})"); + Assert.InRange(centroidY, stripeStart + stripeWidth / 2.0 - 0.35, stripeStart + stripeWidth / 2.0 + 0.35); + Assert.True(predictedBias > 0.6, "the case is too weak to catch the eye-offset bug"); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// Red-weighted column centroid above over [startX, endX), pixel-centre convention. + private static double RedCentroidX(byte[] colorBytes, int startX, int endX, float background) + { + double num = 0, den = 0; + for (int y = 4; y < Size - 4; y++) + for (int x = startX; x < endX; x++) + { + double w = Math.Max(0f, ReadHalf(colorBytes, x, y, 0, 8) - background); + num += w * (x + 0.5); den += w; + } + return den > 0 ? num / den : double.NaN; + } + + private static double RedCentroidY(byte[] colorBytes, int startY, int endY, float background) + { + double num = 0, den = 0; + for (int x = 4; x < Size - 4; x++) + for (int y = startY; y < endY; y++) + { + double w = Math.Max(0f, ReadHalf(colorBytes, x, y, 0, 8) - background); + num += w * (y + 0.5); den += w; + } + return den > 0 ? num / den : double.NaN; + } + + /// 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); + 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, SetupQueue commands, TextureManager textures, PipelineKeyState state, + RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, + SharedLayoutTestBinding 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 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; + samplers[declared.Name] = new SharedLayoutTestBinding.SampledTexture(textureByName[declared.Name], samplerState); + } + + VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = targets.FormatsOf(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); + } + descriptors.Transition(commandBuffer, Array.Empty()); + + 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, PipelineKeyState.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); + + descriptors.Bind(commandBuffer, program, samplers, record: uniformBuffer); + + 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 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]; + Array.Fill(data, value); + fixed (float* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 4); + } + } + + 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( + VulkanContext context, SetupQueue 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); + } + + 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) + { + 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.Render.Vulkan.Tests/TaaSharpenTests.cs b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs new file mode 100644 index 00000000..ab9ba3fd --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaSharpenTests.cs @@ -0,0 +1,491 @@ +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; + +/// +/// Drives the real taa-sharpen shader pair (TAA-PLAN.md P5) on the Vulkan +/// backend, the way drives the resolve: load +/// through , build a pipeline against a single +/// RGBA16F attachment, draw the fullscreen triangle and read the result back +/// inside the frame. +/// +/// Two properties matter and neither can be checked from source: sharpness 0 is +/// a true bypass (the output is the input, bit for bit, including values above 1 +/// that a [0,1] clamp would destroy), and a positive sharpness actually raises +/// the contrast across a known edge instead of blurring it. +/// +public class TaaSharpenTests +{ + private readonly ITestOutputHelper _output; + + public TaaSharpenTests(ITestOutputHelper output) => _output = output; + + private const uint Size = 32; + private const int EdgeX = 16; + private const float DarkSide = 0.2f; + private const float BrightSide = 0.8f; + + private static bool TryCreateContext( + ITestOutputHelper output, List messages, out VulkanContext? context) => + GpuTest.TryCreateContext(output, messages, out context); + + // ------------------------------------------------------------------ tests + + /// + /// sharpness = 0 must return the input untouched, bit for bit - that is what + /// makes "TAA sharpen off" indistinguishable from not running the pass. The + /// pattern deliberately includes HDR values above 1 and a non-opaque alpha, + /// both of which the LDR clamp in fsr-rcas.fsh would change. + /// + [SkippableFact] + public unsafe void SharpnessZeroIsBitForBitIdenticalToTheInput() + { + 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 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); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); + byte[] uploaded = UploadPattern(textures, input); + + SharpenTarget output = CreateTarget(textures, targets); + SharpenOnce(context!, commands, textures, state, targets, pipelines, program, descriptors, + input, 0f, output); + + byte[] result = ReadTextureBytes(context!, commands, textures, output.Color, 8); + Assert.Equal(uploaded, result); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// + /// A vertical step edge between two flat halves: sharpening must push the + /// dark column next to the edge darker and the bright column next to it + /// brighter (a larger step than the input had), while the flat interior + /// away from the edge is left alone. + /// + [SkippableFact] + public unsafe void SharpenIncreasesContrastAcrossAKnownEdge() + { + 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 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); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); + UploadEdge(textures, input); + + 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); + + int row = (int)Size / 2; + float dark = ReadHalf(result, EdgeX - 1, row, 0, 8); + float bright = ReadHalf(result, EdgeX, row, 0, 8); + _output.WriteLine($"edge after sharpen: {dark} | {bright} (input {DarkSide} | {BrightSide})"); + + Assert.True(dark < DarkSide - 0.01f, $"dark side did not darken: {dark}"); + Assert.True(bright > BrightSide + 0.01f, $"bright side did not brighten: {bright}"); + float inputStep = BrightSide - DarkSide; + Assert.True(bright - dark > inputStep, "local contrast did not increase"); + // Nothing may run away: RCAS's lobe is bounded, so the overshoot is + // small, not a doubling of the step. + Assert.True(bright - dark < inputStep * 2f, "overshoot is out of RCAS's bounded range"); + + // Flat interior, four texels away from the edge and from the border, + // is untouched by a filter whose ring is all one value. + for (int y = 4; y < Size - 4; y++) + { + Assert.InRange(ReadHalf(result, 4, y, 0, 8), DarkSide - 0.005f, DarkSide + 0.005f); + Assert.InRange(ReadHalf(result, (int)Size - 5, y, 0, 8), BrightSide - 0.005f, BrightSide + 0.005f); + } + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + /// + /// 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 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); + 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 + /// bypass does. + /// + [SkippableFact] + public unsafe void SharpnessScalesTheEffectMonotonically() + { + 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 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); + ShaderProgramResources program = LoadProgram(context!, compiler, state); + + int input = textures.Create(Size, Size, Format.R16G16B16A16Sfloat); + UploadEdge(textures, input); + + float off = EdgeStep(context!, commands, textures, state, targets, pipelines, program, + descriptors, input, 0f); + float half = EdgeStep(context!, commands, textures, state, targets, pipelines, program, + descriptors, input, 0.5f); + float full = EdgeStep(context!, commands, textures, state, targets, pipelines, program, + descriptors, input, 1f); + _output.WriteLine($"edge step: off={off} half={half} full={full}"); + + Assert.Equal(BrightSide - DarkSide, off, 2); + Assert.True(half > off, "half strength did not sharpen at all"); + Assert.True(full > half, "full strength did not sharpen more than half"); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + private unsafe float EdgeStep( + VulkanContext context, SetupQueue commands, TextureManager textures, PipelineKeyState state, + RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, + SharedLayoutTestBinding descriptors, int input, float sharpness) + { + SharpenTarget output = CreateTarget(textures, targets); + SharpenOnce(context, commands, textures, state, targets, pipelines, program, descriptors, + input, sharpness, output); + byte[] result = ReadTextureBytes(context, commands, textures, output.Color, 8); + int row = (int)Size / 2; + return ReadHalf(result, EdgeX, row, 0, 8) - ReadHalf(result, EdgeX - 1, row, 0, 8); + } + + // ------------------------------------------------------------------ setup + + private static ShaderProgramResources LoadProgram( + VulkanContext context, ShaderCompiler compiler, PipelineKeyState state) + { + Dictionary files = ShaderCorpus.LoadShaderFiles(); + Dictionary includes = ShaderCorpus.LoadIncludes(); + List stages = ShaderCorpus.BuildProgram( + "taa-sharpen", 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; + } + + private sealed class SharpenTarget + { + public int Color; + public int Framebuffer; + } + + private static SharpenTarget CreateTarget(TextureManager textures, RenderTargetManager targets) + { + var set = new SharpenTarget + { + Color = textures.Create(Size, Size, Format.R16G16B16A16Sfloat), + }; + set.Framebuffer = targets.Create(Size, Size); + targets.Attach(set.Framebuffer, 0, set.Color); + return set; + } + + // ------------------------------------------------------------------- draw + + private static unsafe void SharpenOnce( + VulkanContext context, SetupQueue commands, TextureManager textures, PipelineKeyState state, + RenderTargetManager targets, GraphicsPipelineCache pipelines, ShaderProgramResources program, + SharedLayoutTestBinding descriptors, int input, float sharpness, SharpenTarget output) + { + SetUniformFloats(program, "inputTexelSize", new[] { 1f / Size, 1f / Size }); + SetUniformFloats(program, "sharpness", new[] { sharpness }); + + 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); + } + + // The engine samples the resolved colour with a linear, clamp-to-edge + // sampler; at texel centres that is an exact fetch, which is what makes + // the bypass bit-for-bit. + var samplerState = SamplerState.Default with + { + MagFilter = Filter.Linear, + MinFilter = Filter.Linear, + AddressU = SamplerAddressMode.ClampToEdge, + AddressV = SamplerAddressMode.ClampToEdge, + }; + + 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; + samplers[declared.Name] = new SharedLayoutTestBinding.SampledTexture(input, samplerState); + } + + VulkanFramebuffer bound = targets.Get(output.Framebuffer)!; + int formatsId = targets.FormatsIdOf(bound); + RenderTargetFormats formats = targets.FormatsOf(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; + + foreach (VulkanTexture texture in sampledTextures) + { + textures.TransitionTexture(commandBuffer, texture, ImageLayout.ShaderReadOnlyOptimal); + } + descriptors.Transition(commandBuffer, Array.Empty()); + + 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, PipelineKeyState.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); + + descriptors.Bind(commandBuffer, program, samplers, record: uniformBuffer); + + 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); + Assert.True(location >= 0, "uniform not found: " + name); + + 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); + } + + // --------------------------------------------------------------- textures + + /// + /// A pattern with a value above 1 in every channel somewhere and a + /// non-opaque alpha, uploaded and returned as the exact bytes the bypass + /// has to reproduce. + /// + private static unsafe byte[] UploadPattern(TextureManager textures, int textureId) + { + 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)(x / (float)Size); + data[i + 1] = (Half)(y / (float)Size); + data[i + 2] = (Half)(((x + y) % 8 == 0) ? 3.5f : 0.125f); + data[i + 3] = (Half)0.25f; + } + fixed (Half* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 8); + } + + return System.Runtime.InteropServices.MemoryMarshal.AsBytes(data.AsSpan()).ToArray(); + } + + /// A vertical step edge: DarkSide left of EdgeX, BrightSide from it on. + private static unsafe void UploadEdge(TextureManager textures, int textureId) + { + 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 < EdgeX ? DarkSide : BrightSide); + data[i] = value; + data[i + 1] = value; + data[i + 2] = value; + data[i + 3] = (Half)1f; + } + fixed (Half* pixels = data) + { + textures.Upload(textureId, 0, 0, 0, Size, Size, (IntPtr)pixels, 8); + } + } + + // --------------------------------------------------------------- readback + + private static unsafe byte[] ReadTextureBytes( + VulkanContext context, SetupQueue 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); + } +} diff --git a/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs b/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs new file mode 100644 index 00000000..ace38217 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaSkyMotionTests.cs @@ -0,0 +1,749 @@ +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 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 game's CameraMatrixOrigin is a look-at whose eye sits at LocalEyePos, + /// about 1.7 blocks above the origin the terrain is drawn relative to. A + /// still camera must then still write a zero vector on every sky pixel. The + /// original pass took the reconstructed far point's position as the view + /// direction, which carries that eye offset and projected it into a fixed + /// eye / far * (rows / 2) / tan(fov / 2) pixel error: 0.6 px at 1490 rows and + /// 3000 blocks, measured in game on both backends (2026-09-11). The far + /// plane here is short so the same error is several decode steps at 64 rows. + /// + [SkippableTheory] + [InlineData(1.7, 0.0)] + [InlineData(1.7, -0.35)] + [InlineData(25.0, 0.2)] + public void AStillCameraAboveTheOriginWritesZeroSkyMotion(double eyeHeight, double pitch) + { + Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets."); + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + + const double near = 0.0689, far = 60.0, fov = 70.0 * Math.PI / 180.0; + double[] projection = Mat4d.Perspective(Mat4d.Create(), fov, 1.0, near, far); + // View = pitch about X after moving the eye to the origin: the shape of + // Camera.GetCameraMatrix(originPos, ...) for a camera at (0, eye, 0). + double[] view = Mat4d.Identity(Mat4d.Create()); + view = Mat4d.RotateX(view, view, pitch); + view = Mat4d.Translate(view, view, 0.0, -eyeHeight, 0.0); + double[] viewProj = Mat4d.Mul(Mat4d.Create(), projection, view); + double[] inverse = Mat4d.Invert(Mat4d.Create(), viewProj); + Assert.NotNull(inverse); + float[] invF = Array.ConvertAll(inverse!, v => (float)v); + float[] prevF = Array.ConvertAll(viewProj, v => (float)v); + + // What the original formulation would have produced, so the test's + // sensitivity is stated in the output rather than assumed. + double predictedBias = eyeHeight / far * (Size / 2.0) / Math.Tan(fov / 2.0); + _output.WriteLine($"eye {eyeHeight}, pitch {pitch}: far-point-as-direction would bias by ~{predictedBias:F2} px"); + + using (device) + { + Result result = RenderSkyMotion(device!, coverage: 0f, invViewProjJittered: invF, prevViewProj: prevF); + foreach ((int x, int y) in new[] { (32, 32), (8, 56), (56, 8), (20, 44) }) + { + Decoded pixel = result.At(x, y); + _output.WriteLine($"({x}, {y}): mv = ({pixel.MotionX}, {pixel.MotionY}), writerDepth {pixel.WriterDepth}"); + Assert.True(pixel.WriterDepth > 0.99f, "the pass did not cover this sky pixel"); + Assert.InRange(pixel.MotionX, -0.3f, 0.3f); + Assert.InRange(pixel.MotionY, -0.3f, 0.3f); + } + Assert.True(predictedBias > 0.6, "the case is too weak to catch the eye-offset bug at this decode step"); + } + } + + /// + /// 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, + float[]? invViewProjJittered = null, + float[]? prevViewProj = null) + { + VulkanDevice 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", invViewProjJittered ?? InverseJittered(jitterX, jitterY)); + SetMatrix(seam, program, "taaPrevViewProj", prevViewProj ?? 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(VulkanDevice 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(VulkanDevice 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(VulkanDevice 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(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(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(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(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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs new file mode 100644 index 00000000..d5938709 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TaaStandardMotionWriterTests.cs @@ -0,0 +1,729 @@ +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, + }; + + /// + /// 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 + + /// + /// 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); + } + } + + /// + /// 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 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; } + } + + /// + /// 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, + float[]? previousView = null, + float[]? previousProjection = null, + float? reactive = null) + { + VulkanDevice 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", previousProjection ?? Identity); + SetMatrix(seam, program, "prevViewMatrix", previousView ?? Identity); + SetMatrix(seam, program, "prevModelMatrix", previousModelMatrix); + SetInt(seam, program, "taaHistoryValid", historyValid); + 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); + + 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, + decoded[offset + 3] / 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(VulkanDevice 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-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(VulkanDevice 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(VulkanDevice 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(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(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(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(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( + 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(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(VulkanDevice 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, VulkanDevice 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( + VulkanDevice 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) => + GpuTest.TryCreateDevice(output, out device); + + private static void AssertClean(VulkanDevice seam) => GpuTest.AssertClean(seam); + + 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.Render.Vulkan.Tests/TextureDumpTests.cs b/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs new file mode 100644 index 00000000..d4aa655f --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TextureDumpTests.cs @@ -0,0 +1,156 @@ +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) { } + } + } + } + + /// + /// 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/TextureManagerTests.cs b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs new file mode 100644 index 00000000..0372fe3c --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TextureManagerTests.cs @@ -0,0 +1,370 @@ +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 +{ + [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; + + private static bool TryCreateContext(ITestOutputHelper output, out VulkanContext? context) => + GpuTest.TryCreateContext(output, null, out context); + + [SkippableFact] + public void TextureIdsBehaveLikeGlNamesIncludingReuse() + { + Skip.IfNot(TryCreateContext(_output, out VulkanContext? context), "No usable Vulkan device."); + using (context) + { + 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); + + // 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + + 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); + } + } + + /// + /// 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 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"); + + // 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(3f, 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 + /// 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + + 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 = GpuTest.ContextOptions(messages); + Skip.IfNot(VulkanContext.TryCreate(options, out VulkanContext? context, out string? reason), reason ?? ""); + + using (context) + { + 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); + + 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); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(messages); + } + } + + [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 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); + + // 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 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); + + 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + + 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 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); + 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/TimelineLifetimeTests.cs b/Optimum.Render.Vulkan.Tests/TimelineLifetimeTests.cs new file mode 100644 index 00000000..0463c23d --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/TimelineLifetimeTests.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Optimum.Render.Vulkan.Core; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +/// +/// The deferred-destruction rules of against a fake +/// timeline, no device: an entry is never destroyed before both of its recorded +/// timeline values completed, always destroyed at the first collect after, and +/// ready entries go in the order they were retired. +/// +public class TimelineLifetimeTests +{ + 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 Tracked : IDisposable + { + private readonly List? _order; + public int Name { get; } + public int DisposeCount { get; private set; } + public Action? OnDispose { get; set; } + + public Tracked(int name, List? order = null) + { + Name = name; + _order = order; + } + + public bool Disposed => DisposeCount > 0; + + public void Dispose() + { + DisposeCount++; + _order?.Add(Name); + OnDispose?.Invoke(); + } + } + + [Fact] + public void NothingIsFreedBeforeItsFrameValueCompleted() + { + var clock = new FakeClock { FrameRecorded = 5 }; + var queue = new RetireQueue(clock); + var resource = new Tracked(1); + queue.Retire(resource); + + for (ulong completed = 0; completed < 5; completed++) + { + clock.FrameCompleted = completed; + Assert.Equal(0, queue.Collect()); + Assert.False(resource.Disposed, $"freed with Frame completed at {completed}, recorded at 5"); + Assert.Equal(1, queue.PendingCount); + } + + clock.FrameCompleted = 5; + Assert.Equal(1, queue.Collect()); + Assert.True(resource.Disposed); + Assert.Equal(0, queue.PendingCount); + } + + [Fact] + public void BothTimelinesMustPass() + { + var clock = new FakeClock { FrameRecorded = 3, TransferRecorded = 7 }; + var queue = new RetireQueue(clock); + var resource = new Tracked(1); + queue.Retire(resource); + + clock.FrameCompleted = 100; + clock.TransferCompleted = 6; + queue.Collect(); + Assert.False(resource.Disposed, "freed before its Transfer value completed"); + + clock.FrameCompleted = 2; + clock.TransferCompleted = 7; + queue.Collect(); + Assert.False(resource.Disposed, "freed before its Frame value completed"); + + clock.FrameCompleted = 3; + queue.Collect(); + Assert.True(resource.Disposed); + } + + [Fact] + public void TheValuesAreThoseRecordedAtRetirementNotAtCollection() + { + var clock = new FakeClock { FrameRecorded = 2 }; + var queue = new RetireQueue(clock); + var resource = new Tracked(1); + queue.Retire(resource); + + // Later frames are reserved; they cannot reference a resource released before them. + clock.FrameRecorded = 9; + clock.FrameCompleted = 2; + queue.Collect(); + Assert.True(resource.Disposed); + } + + [Fact] + public void EverythingIsFreedExactlyOnceAfterItsValuesPassed() + { + var clock = new FakeClock(); + var queue = new RetireQueue(clock); + var resources = new List(); + for (int i = 0; i < 50; i++) + { + clock.FrameRecorded = (ulong)(i / 5); + clock.TransferRecorded = (ulong)(i / 10); + var resource = new Tracked(i); + resources.Add(resource); + queue.Retire(resource); + } + + clock.FrameCompleted = 9; + clock.TransferCompleted = 4; + Assert.Equal(50, queue.Collect()); + Assert.Equal(0, queue.Collect()); + Assert.Equal(0, queue.PendingCount); + Assert.All(resources, resource => Assert.Equal(1, resource.DisposeCount)); + } + + [Fact] + public void ReadyEntriesAreFreedInRetirementOrder() + { + var order = new List(); + var clock = new FakeClock(); + var queue = new RetireQueue(clock); + + for (int i = 0; i < 20; i++) + { + clock.FrameRecorded = (ulong)(i % 3 + 1); + queue.Retire(new Tracked(i, order)); + } + + // Partial: only the entries recorded at 1 are ready, in their original order. + clock.FrameCompleted = 1; + queue.Collect(); + var expectedFirst = new List(); + for (int i = 0; i < 20; i += 3) expectedFirst.Add(i); + Assert.Equal(expectedFirst, order); + + // The rest, still in retirement order. + order.Clear(); + clock.FrameCompleted = 3; + queue.Collect(); + var expectedRest = new List(); + for (int i = 0; i < 20; i++) + { + if (i % 3 != 0) expectedRest.Add(i); + } + Assert.Equal(expectedRest, order); + } + + /// + /// A finalizer can retire with an older value after the render thread retired + /// with a newer one; the old entry must not wait behind the new one. + /// + [Fact] + public void AnEntryThatHasNotPassedDoesNotHoldBackLaterReadyOnes() + { + var clock = new FakeClock { FrameRecorded = 8 }; + var queue = new RetireQueue(clock); + var late = new Tracked(1); + queue.Retire(late); + clock.FrameRecorded = 4; + var early = new Tracked(2); + queue.Retire(early); + + clock.FrameCompleted = 4; + Assert.Equal(1, queue.Collect()); + Assert.True(early.Disposed); + Assert.False(late.Disposed); + Assert.Equal(1, queue.PendingCount); + } + + [Fact] + public void ADisposeThatRetiresSomethingElseDoesNotDeadlockAndWaitsForTheNextCollect() + { + var clock = new FakeClock { FrameRecorded = 1 }; + var queue = new RetireQueue(clock); + var child = new Tracked(2); + var parent = new Tracked(1) { OnDispose = () => queue.Retire(child) }; + queue.Retire(parent); + + clock.FrameRecorded = 2; + clock.FrameCompleted = 1; + Assert.Equal(1, queue.Collect()); + Assert.True(parent.Disposed); + Assert.False(child.Disposed); + + clock.FrameCompleted = 2; + queue.Collect(); + Assert.True(child.Disposed); + } + + [Fact] + public void RetirementsFromManyThreadsAreAllKeptAndFreed() + { + var clock = new FakeClock { FrameRecorded = 1 }; + var queue = new RetireQueue(clock); + var resources = new List(); + for (int i = 0; i < 1000; i++) resources.Add(new Tracked(i)); + + Parallel.ForEach(resources, resource => queue.Retire(resource)); + Assert.Equal(1000, queue.PendingCount); + + Assert.Equal(0, queue.Collect()); + clock.FrameCompleted = 1; + Assert.Equal(1000, queue.Collect()); + Assert.All(resources, resource => Assert.Equal(1, resource.DisposeCount)); + } + + [Fact] + public void DisposeAllFreesEverythingInOrderRegardlessOfTheTimelines() + { + var order = new List(); + var clock = new FakeClock { FrameRecorded = 50, TransferRecorded = 50 }; + var queue = new RetireQueue(clock); + for (int i = 0; i < 5; i++) queue.Retire(new Tracked(i, order)); + + queue.DisposeAll(); + Assert.Equal(new List { 0, 1, 2, 3, 4 }, order); + Assert.Equal(0, queue.PendingCount); + } + + [Theory] + [InlineData(5UL, 7UL, 5UL)] + [InlineData(9UL, 7UL, 7UL)] + [InlineData(0UL, 0UL, 0UL)] + public void AWaitNeverTargetsAValueNoSubmissionSignalled(ulong requested, ulong signalled, ulong expected) => + Assert.Equal(expected, FrameTimeline.WaitTarget(requested, signalled)); +} 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.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.Tests/ValidationAssert.SyncHazards.cs b/Optimum.Render.Vulkan.Tests/ValidationAssert.SyncHazards.cs new file mode 100644 index 00000000..03157776 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ValidationAssert.SyncHazards.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using Optimum.Render.Vulkan.Core; +using Xunit; + +namespace Optimum.Render.Vulkan.Tests; + +internal static partial class ValidationAssert +{ + private const string WarningPrefix = "[warning] "; + + /// A copy taken under the list's lock; the layers append from other threads. + public static List Snapshot(IReadOnlyCollection messages) + { + lock (messages) return new List(messages); + } + + /// + /// The layer's message id, from the bracket tag + /// puts after the severity: "[error] [SYNC-HAZARD-WRITE-AFTER-WRITE] text". + /// + public static string? MessageId(string message) + { + int start = 0; + if (message.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal)) start = VulkanContext.ErrorPrefix.Length; + else if (message.StartsWith(WarningPrefix, StringComparison.Ordinal)) start = WarningPrefix.Length; + + if (start >= message.Length || message[start] != '[') return null; + int end = message.IndexOf(']', start + 1); + if (end < 0) return null; + string id = message.Substring(start + 1, end - start - 1).Trim(); + return id.Length == 0 ? null : id; + } + + /// Synchronization validation reports under SYNC-* ids (SYNC-HAZARD-WRITE-AFTER-WRITE and friends). + public static bool IsSynchronization(string message) => + MessageId(message) is string id && id.StartsWith("SYNC-", StringComparison.Ordinal); + + public static bool IsBestPractices(string message) => + MessageId(message) is string id && id.Contains("BestPractices", StringComparison.Ordinal); + + /// + /// Fails on any synchronization message that + /// does not pin for the running test. Best-practices messages are counted + /// into and printed by the ledger test, never + /// failed. Call it wherever is called. + /// + public static void NoSyncHazards(IReadOnlyCollection messages, [CallerFilePath] string callerFile = "") + { + List snapshot = Snapshot(messages); + (string testClass, string testMethod) = SyncHazardLedger.CurrentTest(callerFile); + + var seen = new HashSet(StringComparer.Ordinal); + var unexpected = new Dictionary(StringComparer.Ordinal); + foreach (string message in snapshot) + { + if (!IsSynchronization(message)) continue; + string id = MessageId(message)!; + seen.Add(id); + if (KnownSyncHazards.Covers(id, testClass, testMethod)) continue; + unexpected[id] = unexpected.TryGetValue(id, out var entry) + ? (entry.Count + 1, entry.First) + : (1, message); + } + + SyncHazardLedger.Observe(testClass, testMethod, seen); + SyncHazardLedger.Tally(messages, snapshot, testClass); + + if (unexpected.Count == 0) return; + var report = new StringBuilder("unlisted synchronization hazards (id | class | method | count | first message):\n"); + foreach (KeyValuePair pair in unexpected) + { + report.Append(pair.Key).Append(" | ").Append(testClass).Append(" | ").Append(testMethod) + .Append(" | ").Append(pair.Value.Count).Append(" | ").Append(pair.Value.First).Append('\n'); + } + report.Append("A hazard in the renderer is pinned in KnownSyncHazards with its defect and retiring phase; ") + .Append("a hazard in the test's own API use is fixed in the test."); + Assert.Fail(report.ToString()); + } +} + +/// +/// What synchronization validation actually reported during this test run, so +/// the pinned list can only shrink, and how often each best-practices check +/// fired. +/// +internal static class SyncHazardLedger +{ + private static readonly object Gate = new(); + private static readonly Dictionary<(string Class, string Method), HashSet> Observed = new(); + private static readonly SortedDictionary SyncCounts = new(StringComparer.Ordinal); + private static readonly SortedDictionary BestPracticesCounts = new(StringComparer.Ordinal); + private static readonly ConditionalWeakTable> Tallied = new(); + + /// + /// The ledger's own tests feed it synthetic messages; they are kept out of + /// the counts and the printed summary. + /// + private static bool IsSelfTest(string testClass) => + testClass.StartsWith(nameof(SyncHazardLedgerTests), StringComparison.Ordinal); + + /// + /// The xunit test method on the stack. Assertions are often made from a + /// shared helper, so the caller is not necessarily the test. + /// + public static (string Class, string Method) CurrentTest(string callerFile) + { + StackFrame[] frames = new StackTrace(false).GetFrames(); + foreach (StackFrame frame in frames) + { + MethodBase? method = frame.GetMethod(); + if (method?.DeclaringType == null) continue; + if (method.GetCustomAttributes(typeof(FactAttribute), inherit: true).Length > 0) + { + return (method.DeclaringType.Name, method.Name); + } + } + + string stem = callerFile; + int slash = Math.Max(stem.LastIndexOf('/'), stem.LastIndexOf('\\')); + if (slash >= 0) stem = stem.Substring(slash + 1); + if (stem.EndsWith(".cs", StringComparison.Ordinal)) stem = stem.Substring(0, stem.Length - 3); + return (stem, "?"); + } + + /// Notes that a test asserted, and which synchronization ids it had produced by then. + public static void Observe(string testClass, string testMethod, IEnumerable syncIds) + { + lock (Gate) + { + if (!Observed.TryGetValue((testClass, testMethod), out HashSet? ids)) + { + ids = new HashSet(StringComparer.Ordinal); + Observed[(testClass, testMethod)] = ids; + } + ids.UnionWith(syncIds); + } + } + + /// + /// Counts messages by id. A test may assert more than once over the same + /// growing list; each message is counted once. + /// + public static void Tally(object list, List snapshot, string testClass) + { + if (IsSelfTest(testClass)) return; + lock (Gate) + { + StrongBox tallied = Tallied.GetValue(list, _ => new StrongBox(0)); + for (int i = tallied.Value; i < snapshot.Count; i++) + { + string message = snapshot[i]; + string? id = ValidationAssert.MessageId(message); + if (id == null) continue; + if (ValidationAssert.IsSynchronization(message)) Increment(SyncCounts, id); + else if (ValidationAssert.IsBestPractices(message)) Increment(BestPracticesCounts, id); + } + tallied.Value = Math.Max(tallied.Value, snapshot.Count); + } + } + + private static void Increment(SortedDictionary counts, string id) => + counts[id] = counts.TryGetValue(id, out int count) ? count + 1 : 1; + + /// + /// Entries whose test asserted in this run without the hazard occurring. A + /// test that was filtered out, skipped or failed before its assertion never + /// observes, so it cannot make an entry look stale. + /// + public static List StaleEntries(IEnumerable entries) + { + var stale = new List(); + lock (Gate) + { + foreach (KnownSyncHazard entry in entries) + { + if (Observed.TryGetValue((entry.TestClass, entry.TestMethod), out HashSet? ids) + && !ids.Contains(entry.Id)) + { + stale.Add(entry.Id + " | " + entry.TestClass + "." + entry.TestMethod); + } + } + } + return stale; + } + + public static string Summary() + { + var text = new StringBuilder(); + lock (Gate) + { + var keys = new List<(string Class, string Method)>(); + foreach ((string Class, string Method) key in Observed.Keys) + { + if (!IsSelfTest(key.Class)) keys.Add(key); + } + keys.Sort((a, b) => string.CompareOrdinal(a.Class + "." + a.Method, b.Class + "." + b.Method)); + + text.Append("tests that asserted: ").Append(keys.Count).Append('\n'); + text.Append("synchronization messages by id:\n"); + foreach (KeyValuePair pair in SyncCounts) + text.Append(" ").Append(pair.Key).Append(": ").Append(pair.Value).Append('\n'); + text.Append("best-practices messages by id:\n"); + foreach (KeyValuePair pair in BestPracticesCounts) + text.Append(" ").Append(pair.Key).Append(": ").Append(pair.Value).Append('\n'); + text.Append("synchronization ids by test:\n"); + foreach ((string Class, string Method) key in keys) + { + if (Observed[key].Count == 0) continue; + var ids = new List(Observed[key]); + ids.Sort(StringComparer.Ordinal); + text.Append(" ").Append(key.Class).Append('.').Append(key.Method).Append(": ") + .Append(string.Join(", ", ids)).Append('\n'); + } + } + return text.ToString(); + } +} diff --git a/Optimum.Render.Vulkan.Tests/ValidationAssert.cs b/Optimum.Render.Vulkan.Tests/ValidationAssert.cs new file mode 100644 index 00000000..8369e751 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ValidationAssert.cs @@ -0,0 +1,30 @@ +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 partial 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. Synchronization hazards are judged by + /// against the pinned list instead, so a known + /// renderer hazard does not fail every test that happens to trigger it. + /// + public static void NoErrors(IReadOnlyCollection messages) + { + var errors = Snapshot(messages) + .Where(m => m.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal) && !IsSynchronization(m)) + .ToList(); + Assert.True(errors.Count == 0, "validation errors:\n" + string.Join("\n", errors)); + } +} diff --git a/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs b/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs new file mode 100644 index 00000000..16430b8b --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/ValidationFeaturesTests.cs @@ -0,0 +1,183 @@ +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 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 +{ + private readonly ITestOutputHelper _output; + + 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() + { + List enables = + VulkanContext.ParseValidationFeatures(" sync , BEST ,gpu"); + + Assert.Equal(new[] + { + ValidationFeatureEnableEXT.SynchronizationValidationExt, + ValidationFeatureEnableEXT.BestPracticesExt, + ValidationFeatureEnableEXT.GpuAssistedExt, + }, enables); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("nonsense,,")] + public void AnEmptyOrUnknownFeatureListAsksForNothing(string? setting) + { + Assert.Empty(VulkanContext.ParseValidationFeatures(setting)); + } + + /// + /// The real thing: an instance created with features requested must come up. + /// Before the fix the extension was missing while the struct was chained; + /// with the extension enabled the loader validates the struct, so a mistake + /// in it now shows up as a failed vkCreateInstance rather than as silence. + /// + [SkippableFact] + public void AnInstanceComesUpWithTheFeaturesRequested() + { + var messages = new List(); + var options = GpuTest.ContextOptions(messages); + // This test is about the features themselves, whatever the suite default. + options.ValidationFeatures = "sync,best"; + + bool created = VulkanContext.TryCreate(options, out VulkanContext? context, out string? failureReason); + if (!created) _output.WriteLine("Vulkan unavailable: " + failureReason); + Skip.IfNot(created, "No usable Vulkan device."); + + using (context) + { + 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); + } + } + + /// + /// The features struct is only chained when the layer really advertises + /// VK_EXT_validation_features. Naming an extension the layer does not have + /// fails vkCreateInstance with ErrorExtensionNotPresent, and the bootstrap + /// answers a failed context by falling back to OpenGL without a word - so a + /// deprecated extension would turn OPTIMUM_VULKAN_VALIDATION_FEATURES into + /// "Vulkan silently stopped working". + /// + [SkippableFact] + public void TheFeaturesExtensionIsCheckedAgainstTheLayer() + { + using var api = Vk.GetApi(); + const string layer = "VK_LAYER_KHRONOS_validation"; + + Skip.IfNot( + VulkanContext.LayerAdvertisesExtension(api, layer, "VK_EXT_debug_utils") + || VulkanContext.LayerAdvertisesExtension(api, layer, VulkanContext.ValidationFeaturesExtensionName), + "Validation layer not installed."); + + // Whatever the installed layer answers for the real extension, an + // invented one must be answered with false rather than optimistically + // enabled - that is the whole point of the guard. + Assert.False(VulkanContext.LayerAdvertisesExtension(api, layer, "VK_EXT_optimum_not_a_real_extension")); + // And a layer that is not installed advertises nothing. + Assert.False(VulkanContext.LayerAdvertisesExtension( + api, "VK_LAYER_OPTIMUM_not_installed", VulkanContext.ValidationFeaturesExtensionName)); + } + + /// + /// OPTIMUM_VULKAN_VALIDATION doubles as a log path. A Windows path has no + /// forward slash in it and used to be mistaken for the bare "on" switch, + /// which silently redirected the log to the temp file. + /// + [Theory] + [InlineData("1", "/fallback.log")] + [InlineData("true", "/fallback.log")] + [InlineData("/tmp/x.log", "/tmp/x.log")] + [InlineData("C:\\logs\\vulkan.log", "C:\\logs\\vulkan.log")] + public void TheValidationSettingIsAPathOnlyWhenItLooksLikeOne(string setting, string expected) + { + Assert.Equal(expected, VulkanDevice.ResolveValidationLogPath(setting, "/fallback.log")); + } + + [Fact] + public void AnUnsetValidationSettingMirrorsNowhere() + { + Assert.Null(VulkanDevice.ResolveValidationLogPath(null, "/fallback.log")); + } +} diff --git a/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs new file mode 100644 index 00000000..7a7648a1 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/VertexAttributeDefaultTests.cs @@ -0,0 +1,236 @@ +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.R32Uint, 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); + } + + [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. + /// + [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)); + + // 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 _)); + } + + /// + /// 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..e08073ba --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceIntegrationTests.cs @@ -0,0 +1,1775 @@ +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) => + GpuTest.TryCreateDevice(output, out device); + + // 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) => RunTaaResolve(distance, disoccluded, 2, false); + + [SkippableTheory] + [InlineData(2)] + [InlineData(4)] + 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) + { + VulkanDevice 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) + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (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"); + int inspect = LinkProgram(seam, files["taa-resolve.vsh"], """ + #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, + any(notEqual(texelFetch(motionTex, p, 0), vec4(0))) ? 1.0 : 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); + 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][]; + 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); + 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); + 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); + 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) + 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)] + public unsafe void TerrainSamplerUsesNearestTexelsAndBlendsMipLevels(bool linear) + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice 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); + } + } + + /// + /// TAA P5 review: the mip-bias row applies live, which means a LOD bias + /// written into a sampler object that is ALREADY bound to a unit has to reach + /// the next draw without anything rebinding it. + /// + /// That is not obvious on this backend. GL keeps sampler state in the object + /// the driver dereferences at draw time; here the bias is a field of an + /// immutable SamplerState that interns into a VkSampler, and the descriptor + /// set is cached. If the unit's binding were resolved once at BindSampler + /// time, or the descriptor keyed on the sampler id rather than the resolved + /// VkSampler, the slider would move OptimumConfig and change nothing on + /// screen until the next shader reload - the exact failure this pass fixes on + /// the engine side. + /// + /// The source is a 4x4 mip chain with one flat colour per level and the quad + /// is drawn at exactly one texel per pixel, so lambda is 0 and the level the + /// GPU reads is the bias alone. + /// + [SkippableFact] + public unsafe void ALodBiasWrittenToAnAlreadyBoundSamplerChangesTheMipTheGpuReads() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice seam = device!; + const int size = 4; + 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() { + // One texel per pixel on a 4x4 source drawn into a 4x4 target: + // the implicit derivative gives lambda = 0, so every level the + // readback sees comes from the sampler's LOD bias. + color = texture(source, gl_FragCoord.xy / 4.0); + } + """); + int source = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba8, + EnumTexturePixelFormat.Rgba, IntPtr.Zero, true); + byte[] level0 = new byte[size * size * 4]; + byte[] level1 = new byte[2 * 2 * 4]; + byte[] level2 = new byte[4]; + for (int i = 0; i < level0.Length; i += 4) { level0[i] = 255; level0[i + 3] = 255; } + for (int i = 0; i < level1.Length; i += 4) { level1[i + 1] = 255; level1[i + 3] = 255; } + level2[2] = 255; level2[3] = 255; + fixed (byte* data = level0) + seam.UploadTexture2D(source, 0, 0, 0, size, size, EnumTexturePixelFormat.Rgba, (IntPtr)data); + fixed (byte* data = level1) + seam.UploadTexture2D(source, 1, 0, 0, 2, 2, EnumTexturePixelFormat.Rgba, (IntPtr)data); + fixed (byte* data = level2) + seam.UploadTexture2D(source, 2, 0, 0, 1, 1, EnumTexturePixelFormat.Rgba, (IntPtr)data); + + 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); + + // GenSampler's own state, as ShaderRegistry.SetCustomSampler creates + // it for terrainTex; bound once and never rebound below. + int sampler = seam.CreateSampler(linear: false); + + byte[] pixels = new byte[size * size * 4]; + byte[] Draw() + { + seam.BeginFrame(); + seam.BindFramebuffer(framebuffer); + seam.SetViewport(0, 0, size, size); + seam.SetDepthTest(false); + seam.SetCullFace(false); + seam.SetBlend(false, EnumBlendMode.Standard); + seam.UseProgram(program); + seam.SetSamplerUnit(program, "source", 0); + seam.BindTexture(0, source); + seam.DrawFullscreenTriangle(); + seam.Present(); + fixed (byte* data = pixels) + seam.ReadDefaultFramebuffer(0, 0, size, size, (IntPtr)data); + return pixels; + } + + seam.BindSampler(0, sampler); + byte[] unbiased = (byte[])Draw().Clone(); + Assert.Equal(new byte[] { 255, 0, 0, 255 }, unbiased[..4]); + + // The slider's move: the sampler object is already bound to unit 0 + // and nothing rebinds it. + seam.SetSamplerParameter(sampler, OptimumGlConstants.TextureLodBias, 1f); + byte[] biased = (byte[])Draw().Clone(); + Assert.Equal(new byte[] { 0, 255, 0, 255 }, biased[..4]); + + // And back, so the effect is the bias and not a one-way cache miss. + seam.SetSamplerParameter(sampler, OptimumGlConstants.TextureLodBias, 0f); + Assert.Equal(new byte[] { 255, 0, 0, 255 }, Draw()[..4]); + + AssertClean(seam); + } + } + + [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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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 + /// against the interfaces rather than any concrete type. + /// + internal sealed class TestShader : IShader + { + public EnumShaderType Type { get; set; } + public string Code { get; set; } = ""; + public string PrefixCode { get; set; } = ""; + public bool Compile() => true; + } + + internal 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; + } + + internal static int LinkProgram( + 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 }; + + 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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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); + } + } + + /// + /// 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) + { + VulkanDevice 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) + { + VulkanDevice 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) + { + VulkanDevice 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); + } + } + + /// + /// 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) + { + VulkanDevice 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) + { + 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. + 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(VulkanDevice 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(VulkanDevice 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, or on an unpinned synchronization hazard. + /// + private static void AssertNoValidationErrors(VulkanDevice device) => GpuTest.AssertClean(device); + + /// + /// 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. 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() + { + Skip.IfNot(TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device."); + using (device) + { + VulkanDevice 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 sky; + in vec2 uv; + out vec4 outColor; + void main(void) { outColor = texture(sky, 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); + + // 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(); + seam.BindFramebuffer(framebuffer); + seam.UseProgram(program); + 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 frame set naming the texture"); + + 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, "sky", 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(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) + { + 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); + } + } + + /// + /// 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) + { + VulkanDevice 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); + } + } + + /// + /// 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) + { + VulkanDevice 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(VulkanDevice device) => GpuTest.AssertClean(device); +} diff --git a/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs b/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs new file mode 100644 index 00000000..ba1184f0 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/VulkanDeviceTests.cs @@ -0,0 +1,410 @@ +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 = GpuTest.ContextOptions(messages); + + 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 = GpuTest.ContextOptions(); + options.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 SetupQueue(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..85388e70 --- /dev/null +++ b/Optimum.Render.Vulkan.Tests/WorldRenderPathTests.cs @@ -0,0 +1,472 @@ +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) => + GpuTest.TryCreateContext(output, messages, out context); + + /// + /// 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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + 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)); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + // 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); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + var state = new PipelineKeyState(); + using var targets = new RenderTargetManager(context!, textures); + 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); + + // 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); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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 SetupQueue(context!); + using var textures = new TextureManager(context!, commands.Uploads); + 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); + + 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); + + ValidationAssert.NoErrors(messages); + + ValidationAssert.NoSyncHazards(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, SetupQueue commands, RenderTargetManager targets, + 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 = targets.FormatsOf(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, PipelineKeyState.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, SetupQueue 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, 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, SetupQueue 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]; + } + +} 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/Core/BindlessSlots.cs b/Optimum.Render.Vulkan/Core/BindlessSlots.cs new file mode 100644 index 00000000..5f703270 --- /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 + /// 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 + /// 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..5fe960c3 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/BindlessTextureTable.cs @@ -0,0 +1,441 @@ +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 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 +/// 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 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); + + 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 = CreateSetLayout(context, 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) + { + // 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) + { + 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 + + /// 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]; + 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 = _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. + _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/CacheFileWriter.cs b/Optimum.Render.Vulkan/Core/CacheFileWriter.cs new file mode 100644 index 00000000..1759b5e8 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/CacheFileWriter.cs @@ -0,0 +1,86 @@ +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 +{ + internal const int MoveAttempts = 5; + + /// Writes to ; false when it could not. + 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 + { + 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 + { + replace(temporary, path); + return true; + } + catch (Exception error) when (IsTransient(error) && attempt < MoveAttempts) + { + 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/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/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/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 new file mode 100644 index 00000000..411be914 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DescriptorCache.cs @@ -0,0 +1,450 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Shaders; +using Silk.NET.Vulkan; + +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// 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, + ImageLayout Layout = ImageLayout.ShaderReadOnlyOptimal); + +/// 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. +/// +/// 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); + hash.Add(sampler.Resource); + hash.Add((int)sampler.Layout); + } + foreach (BufferBindingValue buffer in buffers) + { + hash.Add(buffer.Binding); + hash.Add(buffer.Buffer.Handle); + hash.Add(buffer.Offset); + hash.Add(buffer.Range); + hash.Add(buffer.Resource); + } + _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 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 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; + 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 CachedSet existing)) + { + Hits++; + return existing.Set; + } + + Misses++; + CachedSet cached = Allocate(layout); + Write(_context, cached.Set, contents); + _sets[contents] = cached; + Index(contents); + return cached.Set; + } + + /// + /// 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 (resource != 0) _pendingReleases.Enqueue(resource); + } + + /// + /// 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)) + { + if (!_byResource.Remove(resource, out List? keys)) continue; + + 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, 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) + { + slot.Remaining = 0; + slot = GrowPool(); + result = AllocateFrom(slot, layout, out set); + if (result != Result.Success) + { + throw new InvalidOperationException("vkAllocateDescriptorSets failed: " + result); + } + } + + 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 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 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. 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] + { + new DescriptorPoolSize(DescriptorType.UniformBufferDynamic, SetsPerPool * 2), + new DescriptorPoolSize(DescriptorType.CombinedImageSampler, SetsPerPool * 8), + new DescriptorPoolSize(DescriptorType.StorageBuffer, SetsPerPool * (uint)SetConvention.StorageSetBindingCount), + }; + + var createInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + Flags = flags, + PoolSizeCount = 3, + PPoolSizes = sizes, + MaxSets = maxSets, + }; + + if (context.Api.CreateDescriptorPool(context.Device, &createInfo, null, out DescriptorPool pool) + != Result.Success) + { + throw new InvalidOperationException("vkCreateDescriptorPool failed"); + } + return pool; + } + + internal static void Write(VulkanContext context, 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, + // 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 + { + 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'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 == SetConvention.StorageSet + ? SharedPipelineLayout.StorageSetDescriptorType(buffer.Binding) + : DescriptorType.UniformBufferDynamic, + 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(); + _byResource.Clear(); + foreach (PoolSlot slot in _pools) + { + _context.Api.DestroyDescriptorPool(_context.Device, slot.Pool, null); + } + _pools.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs b/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs new file mode 100644 index 00000000..5ac031fc --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DescriptorIndexingFloor.cs @@ -0,0 +1,90 @@ +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; + + /// + /// 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; + + /// + /// 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 and set 2's program record are dynamic and share the layout + // with the update-after-bind set. + AtLeast(missing, "maxDescriptorSetUpdateAfterBindUniformBuffersDynamic", + support.MaxDescriptorSetUpdateAfterBindUniformBuffersDynamic, RequiredDynamicUniformBuffers); + 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/DynamicStateCache.cs b/Optimum.Render.Vulkan/Core/DynamicStateCache.cs new file mode 100644 index 00000000..294f7a79 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/DynamicStateCache.cs @@ -0,0 +1,142 @@ +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, + /// 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. +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; + /// + /// 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; +} + +/// +/// 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.Everything; + } + 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; + } + if (_last.ColorWrite != next.ColorWrite) dirty |= DynamicStateDirty.ColorWrite; + if (_last.BlendStateId != next.BlendStateId) dirty |= DynamicStateDirty.ColorBlend; + } + + _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 new file mode 100644 index 00000000..6e1c0f96 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/FrameRing.cs @@ -0,0 +1,534 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +using Buffer = Silk.NET.Vulkan.Buffer; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +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. +/// +/// 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 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 +/// uniform cursor keeps counting, so snapshots taken before a partial submit stay +/// valid after it. +/// +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; + 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(); + private int _commandBuffersUsed; + private ulong _cursor; + private bool _disposed; + + /// The slot's position in the ring. + public int Index { get; } + + public CommandPool CommandPool { get; } + public CommandBuffer CommandBuffer { get; private set; } + + /// The Frame timeline value the command buffer being recorded signals when submitted. + public ulong FrameValue { get; private set; } + + /// + /// The value of this slot's newest accepted submission, 0 before the first. + /// The next frame to use the slot waits for it. + /// + public ulong LastSignalledValue { get; private set; } + + /// Partial submissions in the current frame. + public int PartialSubmits { get; private set; } + + public FrameSlot(VulkanContext context, FrameTimeline timeline, UploadManager uploads, VulkanBuffer uniformRing, + ulong regionStart, ulong regionSize, int index = 0, LatencySubmitTag? latency = null) + { + _latency = latency ?? new LatencySubmitTag(); + _context = context; + _timeline = timeline; + _uploads = uploads; + _uniformRing = uniformRing; + _regionStart = regionStart; + _regionSize = regionSize; + _alignment = FrameRing.OffsetAlignment(context.Capabilities); + Index = index; + + var poolInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + QueueFamilyIndex = context.GraphicsQueueFamily, + Flags = CommandPoolCreateFlags.TransientBit, + }; + context.Api.CreateCommandPool(context.Device, &poolInfo, null, out CommandPool commandPool); + CommandPool = commandPool; + } + + /// + /// Recycles the slot for a frame whose first command buffer signals + /// . The caller has already waited for the last + /// submission that used the slot, so resetting the pool is legal. + /// + public void Begin(ulong frameValue) + { + _context.Api.ResetCommandPool(_context.Device, CommandPool, 0); + _cursor = 0; + _commandBuffersUsed = 0; + PartialSubmits = 0; + FrameValue = 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; + CommandBuffer commandBuffer; + if (_commandBuffersUsed < _commandBuffers.Count) + { + commandBuffer = _commandBuffers[_commandBuffersUsed]; + } + else + { + var allocateInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = CommandPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + VulkanResult.Check(api.AllocateCommandBuffers(_context.Device, &allocateInfo, &commandBuffer), + "vkAllocateCommandBuffers for a frame slot"); + _commandBuffers.Add(commandBuffer); + } + _commandBuffersUsed++; + RecordingSerial = (ulong)System.Threading.Interlocked.Increment(ref s_recordingSerials); + + var begin = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + VulkanResult.Check(api.BeginCommandBuffer(commandBuffer, &begin), + "vkBeginCommandBuffer for a frame slot"); + CommandBuffer = 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); + } + + /// + /// 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; + } + + /// + /// Submits what the frame has recorded so far and continues in a new command + /// buffer of this slot, under a newly reserved Frame value. Nothing waits and + /// nothing is reset. The caller closes any open rendering scope first (and + /// must not have an occlusion query open). Returns the value just signalled. + /// + public ulong SubmitPartial() + { + ulong submitted = FrameValue; + Submit(default, default, default, 0); + PartialSubmits++; + FrameValue = _timeline.ReserveFrame(); + StartCommandBuffer(); + return submitted; + } + + /// + /// 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 ulong EndFrameAndSubmit() + { + ulong submitted = FrameValue; + Submit(default, default, default, 0); + VulkanStats.NoteUniformRingUse(_cursor, _regionSize); + return submitted; + } + + /// + /// 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* 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. + 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 + { + uint signalCount = 0; + if (signalSemaphore.Handle != 0) + { + signals[signalCount] = signalSemaphore; + signalValues[signalCount] = 0; + signalCount++; + } + signals[signalCount] = _timeline.Frame; + signalValues[signalCount] = FrameValue; + signalCount++; + + 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 : waitValues, + SignalSemaphoreValueCount = signalCount, + 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 = chain, + CommandBufferCount = commandBufferCount, + PCommandBuffers = commandBuffers, + WaitSemaphoreCount = waitCount, + PWaitSemaphores = waitCount == 0 ? null : waits, + PWaitDstStageMask = waitCount == 0 ? null : waitStages, + SignalSemaphoreCount = signalCount, + PSignalSemaphores = signals, + }; + + 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 + { + _uploads.ExitSubmit(); + } + VulkanStats.NoteWait(WaitSite.QueueSubmit, submitStart); + _timeline.NoteFrameSubmitted(FrameValue); + LastSignalledValue = FrameValue; + } + + public ulong UniformBytesUsed => _cursor; + public ulong UniformCapacity => _regionSize; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _context.Api.DestroyCommandPool(_context.Device, CommandPool, null); + } +} + +/// +/// Rotates through a small number of frame slots, paced by the Frame timeline. +/// +/// 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. +/// +/// Frames use the slots in turn. Before a frame starts, it waits for the newest +/// Frame value its slot signalled: the end of the frame that last used it (or of +/// that frame's last partial submission). Without partial submissions that is +/// frame n - FramesInFlight. That wait is the only CPU wait the ring makes +/// in steady state. +/// +/// 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 FrameTimeline _timeline; + private readonly RetireQueue _retired; + private readonly UploadManager _uploads; + private readonly VulkanAllocator _allocator; + private readonly LatencySubmitTag _latency = new(); + private int _index = -1; + private bool _disposed; + + 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); + _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.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 = OffsetAlignment(context.Capabilities); + ulong regionSize = uniformRingSize / (ulong)framesInFlight / alignment * alignment; + _slots = new FrameSlot[framesInFlight]; + for (int i = 0; i < framesInFlight; 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. + /// + 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; + + /// 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; + + public FrameSlot Current => _index < 0 + ? throw new InvalidOperationException("BeginFrame has not been called yet") + : _slots[_index]; + + /// + /// Starts the next frame: reserves its first Frame value, waits for the last + /// submission of the frame that used its slot before, destroys whatever the + /// timelines say is no longer referenced, and recycles the slot. + /// + public FrameSlot BeginFrame() + { + int index = (_index + 1) % _slots.Length; + FrameSlot slot = _slots[index]; + + 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); + return slot; + } + + /// + /// Submits the current frame's work so far and keeps recording it in the same + /// slot; see . + /// + public ulong SubmitPartial() => Current.SubmitPartial(); + + /// 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. + /// + /// 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; + /// destruction happens at a later BeginFrame, which is. + /// + /// The resource is keyed on the newest Frame and Transfer values reserved so + /// far (every command that could still name it carries one of them or an + /// older one) and destroyed at the first frame start after both completed. + /// + public void DeferDeletion(IDisposable resource) => _retired.Retire(resource); + + public int PendingDeletionCount => _retired.PendingCount; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + // Callers normally wait for the device to go idle first (VulkanDevice.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(); + _timeline.Dispose(); + } +} diff --git a/Optimum.Render.Vulkan/Core/GlEnums.cs b/Optimum.Render.Vulkan/Core/GlEnums.cs new file mode 100644 index 00000000..9e653acf --- /dev/null +++ b/Optimum.Render.Vulkan/Core/GlEnums.cs @@ -0,0 +1,186 @@ +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 + // 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 + 0x8051 => Format.R8G8B8A8Unorm, // GL_RGB8, promoted: RGB is not a + 0x1907 => Format.R8G8B8A8Unorm, // GL_RGB guaranteed attachment format + 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 + // 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), + }; + + /// + /// 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 + 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 TextureMaxLevel = 0x813D; + public const int TextureBorderColor = 0x1004; + public const int TextureCompareModeNone = 0; + public const int TextureCompareRefToTexture = 0x884E; +} 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/MeshManager.cs b/Optimum.Render.Vulkan/Core/MeshManager.cs new file mode 100644 index 00000000..b5692e45 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/MeshManager.cs @@ -0,0 +1,570 @@ +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 UploadManager? _uploads; + 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; + + /// + /// Static meshes on device-local memory, filled through the upload manager's + /// 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; } = true; + + public MeshManager(VulkanContext context, UploadManager? uploads = null) + { + _context = context; + _uploads = uploads; + _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, bool signedCustomShorts = false) + { + 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. + // 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, 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, shortType, + customShorts?.Conversion == DataConversion.NormalizedFloat, + customShorts?.Conversion == DataConversion.Integer, + customShorts?.Instanced ?? false); + + (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); + + 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); + if (ssbo) FillQuadIndices(mesh.Indices); + } + + mesh.Layout = builder.Build(); + mesh.LayoutId = _layouts.Intern(mesh.Layout); + + 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) + { + 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) + { + // 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, MemoryPoolClass.DeviceBuffers); + } + + // 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) + { + if (_freeIds.Count > 0) + { + int reused = _freeIds.Pop(); + _meshes[reused] = mesh; + return reused; + } + + _meshes.Add(mesh); + return _meshes.Count - 1; + } + + /// 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 void FillQuadIndices(VulkanBuffer indices) + { + int count = (int)(indices.Size / sizeof(int)); + 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; + 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); + 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. + /// + /// 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) + { + if (source == IntPtr.Zero || byteCount <= 0) 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 && _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)" + : null; + + if (problem != null) + { + VulkanStats.NoteDroppedMeshWrite(); + if (RenderTrace.Enabled) + { + RenderTrace.Write("mesh write dropped: mesh " + meshId + " slot " + slot + + " offset " + byteOffset + " bytes " + byteCount + ": " + problem); + } + 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); + } + + 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; + + // 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]; + 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, + ulong indirectOffset) + { + VulkanMesh? mesh = Get(meshId); + if (mesh == null || groupCount <= 0) return; + + Bind(commandBuffer, mesh); + + if (indirectScratch.Mapped == IntPtr.Zero || indirectOffset >= indirectScratch.Size) return; + var commands = (DrawIndexedIndirectCommand*)(indirectScratch.Mapped + (nint)indirectOffset); + + 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, + (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 = checked((uint)(byteOffset / sizeof(int))), + VertexOffset = 0, + FirstInstance = 0, + }; + } + } + + 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..2685c1e5 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineCache.cs @@ -0,0 +1,1127 @@ +using Optimum.Render.Vulkan.Shaders; +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; + +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: 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 +/// 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; + + /// + /// 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; + + /// 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; } + + 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; } + + /// 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; + SettingsHash = PipelineKeyLog.SettingsHashFor(tier, dynamicBlend); + + 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(); + + // 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'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; } + + 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, + InitialDataSize = (nuint)(initialData?.Length ?? 0), + PInitialData = initialData is { Length: > 0 } ? data : null, + }; + + if (context.Api.CreatePipelineCache(context.Device, &createInfo, null, out cache) == Result.Success) + { + return true; + } + cache = default; + return false; + } + } + + /// 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; } + } + + /// 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)) + { + Hits++; + return existing; + } + + Misses++; + 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; + } + + /// + /// 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. + /// + /// + /// 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)) + { + 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)) + { + // 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); + 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; + } + + /// + /// 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() + { + if (_preparing) return; + 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; + 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(); + 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 && (_holdForTests || (_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)) + { + // 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) + { + // 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) + { + // 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 }) + { + 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) + { + 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. + 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"); + + // 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) + { + 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, + PSpecializationInfo = specializationInfo, + }); + } + + 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; + // 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, + SrcColorBlendFactor = blend.SrcColor, + DstColorBlendFactor = blend.DstColor, + ColorBlendOp = blend.ColorOp, + SrcAlphaBlendFactor = blend.SrcAlpha, + DstAlphaBlendFactor = blend.DstAlpha, + AlphaBlendOp = blend.AlphaOp, + ColorWriteMask = writeMask, + }; + } + + // 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 + { + 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 = RenderLimits.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, + // 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, + 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, cache, 1, &createInfo, null, out pipeline); + if (result != Result.Success) pipeline = default; + return result; + } + } + 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); + } + } + } + + /// + /// 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(); + 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++) + { + 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 Array.Empty(); + } + + 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) + { + 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) + { + api.DestroyPipelineCache(_context.Device, _driverCache, null); + } + } +} 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/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/PipelineState.cs b/Optimum.Render.Vulkan/Core/PipelineState.cs new file mode 100644 index 00000000..e8a1a554 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/PipelineState.cs @@ -0,0 +1,265 @@ +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; + } + + /// + /// 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 + { + 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/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/Core/RenderTargetManager.cs b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs new file mode 100644 index 00000000..41de6511 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/RenderTargetManager.cs @@ -0,0 +1,786 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Graph; +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[RenderLimits.MaxColorAttachments]; + public int DepthTextureId; + + /// Cached interned id of the attachment formats, or -1 when stale. + public int FormatsId = -1; + + /// + /// 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; +} + +/// +/// Owns framebuffers and drives dynamic rendering scopes. +/// +/// 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 +{ + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly Interner _formats = new(); + + /// 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(); + + private VulkanFramebuffer? _bound; + private bool _renderingActive; + private bool _disposed; + + /// 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[RenderLimits.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: + /// 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; + + /// 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, FrameGraph? graph = null) + { + _context = context; + _textures = textures; + _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) => + 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 < 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.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; + } + + 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.PassExclusion >> index) & 1) == 0; + + 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. + /// + /// 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; + // Left out of the declared pass's scope: sampled directly, not feedback. + if (((_bound.PassExclusion >> i) & 1) != 0) continue; + 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 + /// at the first draw or clear. + /// + public void Bind(CommandBuffer commandBuffer, int framebufferId) + { + VulkanFramebuffer? framebuffer = Get(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; + } + + public void Delete(int framebufferId) + { + VulkanFramebuffer? framebuffer = Get(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) + { + // 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; + } + + 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); + + 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 && ((colorSlots >> i) & 1) == 0) exclusion |= 1u << i; + } + 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. + 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 + + /// + /// Opens a rendering scope if one is not already open, transitioning every + /// participating attachment into its attachment layout. Every bound colour + /// 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) + { + if (_renderingActive && !_needsRestart) return; + if (_bound == null) return; + + bool restarting = _renderingActive; + if (_renderingActive) EndRendering(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++) + { + AttachmentSlot slot = framebuffer.Color[i]; + + if (!InScope(framebuffer, i)) + { + // A null view keeps fragment output i pointed at slot i: an + // unbound slot, or one the declared pass leaves out. + attachments[i] = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = default, + ImageLayout = ImageLayout.Undefined, + LoadOp = AttachmentLoadOp.DontCare, + StoreOp = AttachmentStoreOp.DontCare, + }; + continue; + } + + VulkanTexture? texture = _textures.Get(slot.TextureId); + if (texture == null) + { + attachments[i] = new RenderingAttachmentInfo { SType = StructureType.RenderingAttachmentInfo }; + continue; + } + + // Blend state can change inside the scope, so the attachment is + // declared for the widest colour use (read and write). + if (graph) scopeColour![i] = texture; + else _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.ColorBlend); + + 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) + { + ImageLayout depthLayout = DepthReadOnly + ? ImageLayout.DepthReadOnlyOptimal + : ImageLayout.DepthAttachmentOptimal; + // Read-only depth may be sampled by the draws of this scope. + if (graph) scopeDepth = depth; + else _textures.Require(_barriers, commandBuffer, depth, + DepthReadOnly ? ResourceUsage.DepthReadOnlySampled : ResourceUsage.DepthWrite); + depthAttachment = new RenderingAttachmentInfo + { + SType = StructureType.RenderingAttachmentInfo, + ImageView = depth.View, + ImageLayout = depthLayout, + LoadOp = AttachmentLoadOp.Load, + StoreOp = AttachmentStoreOp.Store, + }; + hasDepth = true; + } + } + + if (graph) + { + _recorder.Prepare(commandBuffer, framebuffer, scopeColour!, scopeDepth, DepthReadOnly, + FormatsIdOf(framebuffer), _framebuffers, attachments, ref depthAttachment); + } + + _barriers.Flush(commandBuffer); + + 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); + } + + // 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++; + VulkanStats.NoteScopeOpened(); + ScopeOpened?.Invoke(commandBuffer); + } + + /// + /// 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; + ScopeClosing?.Invoke(commandBuffer); + _context.Api.CmdEndRendering(commandBuffer); + _renderingActive = false; + ScopeClosed?.Invoke(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); + } + + /// + /// 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; + + // glClearBuffer names a draw buffer, and one that glDrawBuffers left out + // 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: GL keeps an attachment the + // 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 (_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; + + 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); + } + + 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. + /// 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)) + { + // 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) + { + 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(); + 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); + if (!_graph.Enabled) 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. 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 = HighestScopeAttachment(framebuffer) + 1; + var colorFormats = new Format[Math.Max(count, 0)]; + + for (int i = 0; i < count; i++) + { + AttachmentSlot slot = framebuffer.Color[i]; + VulkanTexture? texture = InScope(framebuffer, i) ? _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 = _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 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 DeclaredFormats(VulkanFramebuffer framebuffer, uint colorSlots) + { + int count = 0; + for (int i = 0; i < RenderLimits.MaxColorAttachments; i++) + { + 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 = framebuffer.Color[i].IsBound && ((colorSlots >> 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; + + private static int HighestScopeAttachment(VulkanFramebuffer framebuffer) + { + int highest = -1; + for (int i = 0; i < RenderLimits.MaxColorAttachments; i++) + { + if (InScope(framebuffer, i)) 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/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/ShaderProgramResources.cs b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs new file mode 100644 index 00000000..cc5877af --- /dev/null +++ b/Optimum.Render.Vulkan/Core/ShaderProgramResources.cs @@ -0,0 +1,297 @@ +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 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 +/// 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 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 bool _disposed; + + public int ProgramId { get; } + public ProgramInterfaceLayout Interface { get; } + + public Dictionary Modules { get; } = new(); + + /// The shared pipeline layout this program's pipelines are created against. Not owned. + public PipelineLayout PipelineLayout { get; } + + /// + /// 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 SharedPipelineLayout? StandaloneLayout { get; } + + /// 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; + + /// 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 + /// int uniform; here it is the link between a bound texture and a descriptor. + /// + public Dictionary SamplerUnits { get; } = new(StringComparer.Ordinal); + + /// + /// 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, PipelineLayout sharedLayout = default) + { + _context = context; + 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, translated.Specialization); + + // 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.Order; + } + + if (sharedLayout.Handle == 0) + { + StandaloneLayout = SharedPipelineLayout.CreateStandalone(context); + sharedLayout = StandaloneLayout.Layout; + } + PipelineLayout = sharedLayout; + } + + /// + /// 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, NativeSpecialization? specialization) + { + 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]); + } + 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))); + } + + 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; + } + } + + // ------------------------------------------------------------------ uniforms + + /// + /// 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; + + /// + /// 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; + + /// + /// 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; + + /// 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; + + /// + /// Resolves a uniform name to an opaque location, the way glGetUniformLocation + /// does. + /// + /// 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 + /// 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.PushMembersByName.TryGetValue(name, out UniformMember? pushMember)) + { + return PushLocationBase + pushMember.Offset; + } + + 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); + 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; + + public void NoteSnapshot(uint frame, uint offset) + { + SnapshotFrame = frame; + SnapshotVersion = UniformVersion; + SnapshotOffset = offset; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + Vk api = _context.Api; + // 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 new file mode 100644 index 00000000..c5decfed --- /dev/null +++ b/Optimum.Render.Vulkan/Core/SharedPipelineLayout.cs @@ -0,0 +1,205 @@ +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 | 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 +/// 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 readonly bool _ownsTextureSetLayout; + private bool _disposed; + + public DescriptorSetLayout FrameSetLayout { get; } + public DescriptorSetLayout TextureSetLayout { get; } + 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; + + DescriptorSetLayoutBinding[] frameBindings = FrameBindings(); + DescriptorSetLayoutBinding[] storageBindings = StorageBindings(); + + 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; + } + + /// 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, 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() + { + 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[index++] = new DescriptorSetLayoutBinding + { + Binding = (uint)buffer.Value, + DescriptorType = DescriptorType.StorageBuffer, + DescriptorCount = buffer.Capacity, + StageFlags = Stages, + }; + } + 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) + { + 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); + if (_ownsTextureSetLayout) api.DestroyDescriptorSetLayout(_context.Device, TextureSetLayout, null); + } +} 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/TextureDump.cs b/Optimum.Render.Vulkan/Core/TextureDump.cs new file mode 100644 index 00000000..5ffecd50 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/TextureDump.cs @@ -0,0 +1,448 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using Silk.NET.Vulkan; +using Vintagestory.API.Config; + +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 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 +{ + 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. + /// + /// 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) + { + 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. + /// 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); + return ids; + } + + /// Removes an id from the pending set once it has been written successfully. + public static void Complete(int textureId) => Pending.Remove(textureId); + + /// + /// 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 Path.IsPathRooted(explicitDir) ? Path.GetFullPath(explicitDir) : null; + } + + string? tracePath = Environment.GetEnvironmentVariable("OPTIMUM_RENDER_TRACE"); + if (!string.IsNullOrWhiteSpace(tracePath) && Path.IsPathRooted(tracePath)) + { + string? beside = Path.GetDirectoryName(Path.GetFullPath(tracePath)); + if (!string.IsNullOrWhiteSpace(beside)) return 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 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 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 + /// 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, Format format, + ReadOnlySpan data) + { + if (width <= 0 || height <= 0) return false; + + int bytesPerPixel = BytesPerTexel(format); + if (data.Length < width * height * bytesPerPixel) return false; + + try + { + string? directory = Directory(); + if (directory == null) return false; + System.IO.Directory.CreateDirectory(directory); + string path = Path.Combine(directory, $"{RunPrefix}-texture-{textureId}-{width}x{height}.ppm"); + + // 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); + + var row = new byte[width * 3]; + int stride = width * bytesPerPixel; + + switch (format) + { + 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.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); + 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; + } + } + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + /// + /// 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.R16G16B16A16Unorm => 8, + Format.R32G32B32A32Sfloat => 16, + Format.R32Sfloat => 4, + Format.R16Sfloat => 2, + Format.D32Sfloat => 4, + Format.D16Unorm => 2, + Format.R8Unorm or Format.R8Uint or Format.R8Srgb => 1, + _ => 4, + }; + + /// + /// The GL token for a Vulkan format, for textures created without one + /// ( is 0). The inverse of + /// where that mapping is one to one. + /// + public static int GlInternalFormatOf(Format format) => format switch + { + Format.R8G8B8A8Unorm or Format.R8G8B8A8Srgb or Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb => 0x8058, + Format.R8Unorm => 0x8229, + Format.R16G16B16A16Sfloat => 0x881A, + Format.R16G16B16A16Unorm => 0x805B, + Format.R32G32B32A32Sfloat => 0x8814, + Format.R16Sfloat => 0x822D, + Format.R32Sfloat => 0x822E, + Format.B10G11R11UfloatPack32 => 0x8C3A, + Format.D32Sfloat => 0x8CAC, + Format.D16Unorm => 0x81A5, + _ => 0, + }; + + /// + /// Decodes a raw level-0 readback (, rows in memory + /// order, which is GL order) into the parity dump's shared representation - + /// what glGetTexImage returns on the OpenGL path: RGBA8 bytes for 8-bit + /// unsigned-normalised formats, RGBA float32 for other colour formats (missing + /// channels 0, alpha 1, as GL fills them), one float32 per texel for depth. + /// Returns null for a format the dump does not decode. + /// + public static OptimumTextureReadback? ToParityReadback(Format format, int glInternalFormat, + int width, int height, ReadOnlySpan data) + { + if (width <= 0 || height <= 0) return null; + int texels = width * height; + if (data.Length < texels * BytesPerTexel(format)) return null; + + var readback = new OptimumTextureReadback + { + GlInternalFormat = glInternalFormat, + Width = width, + Height = height, + }; + + switch (format) + { + case Format.R8G8B8A8Unorm or Format.R8G8B8A8Srgb: + readback.Bytes = data.Slice(0, texels * 4).ToArray(); + return readback; + case Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb: + { + var bytes = new byte[texels * 4]; + for (int i = 0; i < texels; i++) + { + bytes[i * 4] = data[i * 4 + 2]; + bytes[i * 4 + 1] = data[i * 4 + 1]; + bytes[i * 4 + 2] = data[i * 4]; + bytes[i * 4 + 3] = data[i * 4 + 3]; + } + readback.Bytes = bytes; + return readback; + } + case Format.R8Unorm: + { + var bytes = new byte[texels * 4]; + for (int i = 0; i < texels; i++) + { + bytes[i * 4] = data[i]; + bytes[i * 4 + 3] = 255; + } + readback.Bytes = bytes; + return readback; + } + case Format.R16G16B16A16Sfloat: + { + var source = MemoryMarshal.Cast(data); + var floats = new float[texels * 4]; + for (int i = 0; i < floats.Length; i++) floats[i] = (float)source[i]; + readback.Floats = floats; + return readback; + } + case Format.R16G16B16A16Unorm: + { + var source = MemoryMarshal.Cast(data); + var floats = new float[texels * 4]; + for (int i = 0; i < floats.Length; i++) floats[i] = source[i] / 65535f; + readback.Floats = floats; + return readback; + } + case Format.R32G32B32A32Sfloat: + readback.Floats = MemoryMarshal.Cast(data).Slice(0, texels * 4).ToArray(); + return readback; + case Format.R16Sfloat: + { + var source = MemoryMarshal.Cast(data); + var floats = new float[texels * 4]; + for (int i = 0; i < texels; i++) + { + floats[i * 4] = (float)source[i]; + floats[i * 4 + 3] = 1f; + } + readback.Floats = floats; + return readback; + } + case Format.R32Sfloat: + { + var source = MemoryMarshal.Cast(data); + var floats = new float[texels * 4]; + for (int i = 0; i < texels; i++) + { + floats[i * 4] = source[i]; + floats[i * 4 + 3] = 1f; + } + readback.Floats = floats; + return readback; + } + case Format.D32Sfloat: + readback.Floats = MemoryMarshal.Cast(data).Slice(0, texels).ToArray(); + return readback; + case Format.D16Unorm: + { + var source = MemoryMarshal.Cast(data); + var floats = new float[texels]; + for (int i = 0; i < texels; i++) floats[i] = source[i] / 65535f; + readback.Floats = floats; + return readback; + } + default: + return null; + } + } + + /// 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/TextureManager.cs b/Optimum.Render.Vulkan/Core/TextureManager.cs new file mode 100644 index 00000000..7942b399 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/TextureManager.cs @@ -0,0 +1,1026 @@ +using System; +using System.Collections.Generic; +using Optimum.Render.Vulkan.Graph; +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. +/// +/// +/// 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, + SamplerMipmapMode MipmapMode, + SamplerAddressMode AddressU, + SamplerAddressMode AddressV, + float LodBias, + bool CompareEnable, + float MaxAnisotropy, + 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 + : Vk.LodClampNone; +} + +/// 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 MemoryAllocation Allocation { get; init; } + public ImageView View { get; init; } + + /// 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; } + + /// + /// The GL internal format token the client asked for, or 0 when the texture + /// was not created through a GL-token entry point. Kept because the Vulkan + /// format can be a promotion (GL_RGB lands in RGBA8 storage), and the parity + /// dump names files by what was requested so both backends pair. + /// + public int GlInternalFormat { get; set; } + + public uint Width { get; init; } + public uint Height { get; init; } + 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; } + + /// Whether the image is 3D (); GL-created textures never are. + public bool Volume { get; init; } + public ImageAspectFlags Aspect { get; init; } + + /// Mutable, as glTexParameter is. + public SamplerState State { get; set; } = SamplerState.Default; + + 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 + /// 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. + /// + /// 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; + } + + /// + /// 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; + _disposed = true; + + Vk api = _context.Api; + foreach (ImageView layerView in _layerViews.Values) + { + 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); + } +} + +/// +/// 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 = state.LodCeiling, + 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 +{ + /// 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 UploadManager _uploads; + private readonly List _textures = new(); + private readonly Stack _freeIds = new(); + private bool _disposed; + + public SamplerCache Samplers { get; } + + 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); + } + + 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) + { + // Under the upload lock, like Delete: an upload from another thread + // looks its texture up again under the same lock. + _uploads.EnterLock(); + try + { + if (_freeIds.Count > 0) + { + int reused = _freeIds.Pop(); + _textures[reused] = texture; + return reused; + } + + _textures.Add(texture); + return _textures.Count - 1; + } + finally + { + _uploads.ExitLock(); + } + } + + /// + /// 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, 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 + // 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); + + 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, + 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"); + } + + 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", poolClass, 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"); + } + + 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), + }; + 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) + { + Image = image, + Allocation = allocation, + View = view, + Format = format, + Width = width, + Height = height, + MipLevels = mipLevels, + Layers = viewLayers, + Cube = cube, + Aspect = aspect, + Usage = usage, + }; + + if (_context.PoisonFreshResources) Poison(texture); + + 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 + /// nobody wrote is loud instead of whatever the allocator's memory held. + /// 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; + + CommandBuffer commandBuffer = _uploads.BeginRecording(inlineInFrame: false); + try + { + TransitionTexture(commandBuffer, texture, ImageLayout.TransferDstOptimal); + var range = new ImageSubresourceRange(texture.Aspect, 0, texture.MipLevels, 0, texture.Layers); + if (texture.Aspect == ImageAspectFlags.DepthBit) + { + var depth = new ClearDepthStencilValue(VulkanPoison.Depth, 0); + _context.Api.CmdClearDepthStencilImage(commandBuffer, texture.Image, + ImageLayout.TransferDstOptimal, &depth, 1, &range); + } + else + { + ClearColorValue color = VulkanPoison.ColorFor(texture.Format); + _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 + { + _uploads.EndRecording(); + } + } + + /// + /// 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, + 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; + + VulkanStats.NoteUploadRequest(); + 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)); + } + + TransitionTexture(commandBuffer, texture, ImageLayout.TransferDstOptimal); + + 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.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. Batched or inline by the same + /// rule as , so it follows the uploads it is built from. + /// + public void GenerateMipmaps(int textureId) + { + VulkanTexture? texture = Get(textureId); + if (texture == null || texture.MipLevels <= 1) return; + + VulkanStats.NoteUploadRequest(); + 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; + + if (_context.CheckpointsAvailable) + { + _context.CmdSetCheckpoint(commandBuffer, CheckpointMarker.Mipmaps(textureId, texture.MipLevels)); + } + + 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); + + // The level is about to be overwritten whole: discard it. + TransitionRange(commandBuffer, texture, level, 1, ResourceUsage.TransferDst, discard: true); + + 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, ResourceUsage.TransferSrc, discard: false); + + mipWidth = nextWidth; + mipHeight = nextHeight; + } + + // 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 + { + _uploads.EndRecording(); + } + } + + /// + /// 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.TextureMaxLevel => state with { MaxLevel = integer }, + 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, + Mipmapped = GlEnums.MinFilterUsesMipmaps(glFilter), + }; + } + + /// + /// 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, + }; + } + + /// + /// 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 + // the Transfer value of any batch that recorded this texture already. + _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; + + _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); + + // 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(); + } + } + + // ------------------------------------------------------------ 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 + // 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) + { + _uploads.NoteUse(commandBuffer, texture); + 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)); + + 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); + } + + private void TransitionRange(CommandBuffer commandBuffer, VulkanTexture texture, + uint baseMip, uint mipCount, ResourceUsage usage, bool discard) + { + lock (_barrierLock) + { + _barriers.Require(texture, baseMip, mipCount, 0, texture.Layers, usage, discard); + _barriers.Flush(commandBuffer); + } + } + + // -------------------------------------------------------------------- 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; + + // 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/VertexLayout.cs b/Optimum.Render.Vulkan/Core/VertexLayout.cs new file mode 100644 index 00000000..67d5d0f4 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VertexLayout.cs @@ -0,0 +1,290 @@ +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); + bool unsigned = IsUnsignedType(type); + return type.ComponentCount switch + { + 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) || + 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/VulkanAllocator.cs b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs new file mode 100644 index 00000000..feec44ad --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanAllocator.cs @@ -0,0 +1,916 @@ +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 +{ + 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; } + + /// 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 + /// 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 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; + + /// + /// 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")} {poolClass} 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(); + } +} + +/// 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. +/// +/// 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. +/// +/// 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 +{ + 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; + + /// 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 + /// 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"; + + /// 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<(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; + // 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; + 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 + { + get + { + lock (_gate) + { + 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) => + 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) + { + if (poolClass == MemoryPoolClass.Dedicated) + { + requiresDedicated = true; + poolClass = InferClass(properties, linear); + } + + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (poolClass == MemoryPoolClass.ReBar) + { + 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 (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) + { + if (candidate.TryAllocate(requirements.Size, requirements.Alignment, out ulong offset)) + { + NoteFilled(candidate); + return Describe(candidate, offset, requirements.Size); + } + } + } + + 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 + { + return AllocateLocked(requirements, typeIndex, MemoryPoolClass.ReBar, linear, what, + requiresDedicated, buffer, image); + } + } + + _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)) + { + throw new InvalidOperationException("a dedicated block could not satisfy " + what); + } + return Describe(block, dedicatedOffset, requirements.Size); + } + + var key = (poolClass, 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)) + { + NoteFilled(candidate); + return Describe(candidate, offset, 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; + 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) + { + _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); + if (block.Class == MemoryPoolClass.Transient) _transientBytes -= Math.Min(_transientBytes, 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) => + 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); + NoteBlockReleased(block); + block.Dispose(); + 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); + + ulong cap = 0; + if (TryFindMemoryType(uint.MaxValue, + MemoryPropertyFlags.DeviceLocalBit | MemoryPropertyFlags.HostVisibleBit, 0, out uint reBarType)) + { + cap = ReBarCapLocked(reBarType); + } + + return new MemorySnapshot( + BlockCountLocked(), _dedicated.Count, _reBarUsed, cap, _reBarMisses, _emptyBlocksFreed, + BudgetExtension, (ulong[])_classBytes.Clone(), (ulong[])_heapUsed.Clone(), heapBudget); + } + } + + /// + /// 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 && (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) + { + 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 new file mode 100644 index 00000000..89da4795 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanContext.cs @@ -0,0 +1,1407 @@ +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; + + /// Comma list of extra layer checks: sync, best, mobile, gpu, gpu-only (). + public string ValidationFeatures = ""; + + /// 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; + + /// + /// Fills freshly created images and host-visible buffers with a loud value + /// before first use (see ). Null reads + /// OPTIMUM_VULKAN_POISON once, at context creation. + /// + 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; + + /// + /// 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). + /// + public TimeSpan AcquireDelayForTests; +} + +/// What the chosen device can do, once it is up. +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; + 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. Applied to a native pipeline's dynamic + /// state, so every draw clamps the same way. + /// + 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; + /// Enabled whenever available; occlusion queries then count samples exactly, like GL_SAMPLES_PASSED. + public bool OcclusionQueryPrecise; + 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 . + public DescriptorIndexingSupport DescriptorIndexing; + + /// 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; + + // ------------------------------------------------------------------ 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. + /// + public bool PipelineCreationCacheControl; +} + +/// +/// 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(); + + /// + /// 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!; + + /// + /// 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(); + + /// 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. + /// Worth being able to check, because "no validation messages" otherwise + /// reads as "nothing is wrong". + /// + public bool ValidationEnabled { get; private set; } + + /// + /// Whether freshly created images and host-visible buffers are filled with + /// values. Fixed for the context's life. + /// + 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"; + + internal static bool PoisonRequested(string? setting) => + !string.IsNullOrWhiteSpace(setting) && setting.Trim() != "0"; + + /// 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; + + /// 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; + 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(); + created.AcquireDelayForTests = options.AcquireDelayForTests; + created.PoisonFreshResources = options.Poison + ?? PoisonRequested(Environment.GetEnvironmentVariable(PoisonVariable)); + 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(); + + // 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 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)" + : ""; + + // 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); + 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, + PApplicationName = applicationName, + ApplicationVersion = new Version32(1, 0, 0), + PEngineName = engineName, + EngineVersion = new Version32(1, 0, 0), + ApiVersion = MinimumApiVersion, + }; + + 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 = chainLayerSettings ? &layerSettings + : chainValidationFeatures ? (void*)&validationFeatures + : null, + 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); + foreach (nint text in settingStrings) SilkMarshal.Free(text); + } + + if (validation) + { + SetUpDebugMessenger(options); + } + + return true; + } + + /// 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 + /// loader-level enumeration, so the layer has to be named explicitly. + /// + internal static bool LayerAdvertisesExtension(Vk api, string layerName, string extensionName) + { + nint layer = SilkMarshal.StringToPtr(layerName); + try + { + uint count = 0; + if (api.EnumerateInstanceExtensionProperties((byte*)layer, &count, null) != Result.Success + || count == 0) + { + return false; + } + + var properties = new ExtensionProperties[count]; + fixed (ExtensionProperties* propertiesPtr = properties) + { + if (api.EnumerateInstanceExtensionProperties((byte*)layer, &count, propertiesPtr) != Result.Success) + { + return false; + } + for (int i = 0; i < count; i++) + { + // The name is a fixed-size buffer, readable only through a pointer. + if (SilkMarshal.PtrToString((nint)propertiesPtr[i].ExtensionName) == extensionName) + { + return true; + } + } + } + return false; + } + finally + { + SilkMarshal.Free(layer); + } + } + + /// Maps the comma list from OPTIMUM_VULKAN_VALIDATION_FEATURES onto layer feature flags. + internal static List ParseValidationFeatures(string? features) + { + var enables = new List(); + foreach (string feature in (features ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + switch (feature.ToLowerInvariant()) + { + 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; + } + + 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) + { + ValidationLayerVersion = VersionString(layersPtr[i].SpecVersion) + + " (implementation " + layersPtr[i].ImplementationVersion + ")"; + 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] "; + // The layer names the check separately (SYNC-HAZARD-WRITE-AFTER-WRITE, + // BestPractices-..., a VUID); current layers no longer repeat it in + // the text, and without it a log line cannot be grouped or pinned. + string? id = SilkMarshal.PtrToString((nint)data->PMessageIdName); + string idTag = string.IsNullOrEmpty(id) ? "" : "[" + id + "] "; + _debugCallback?.Invoke(prefix + idTag + 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"); + // 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) + { + reason = $"{name} lacks {string.Join(", ", missing)}"; + return false; + } + + reason = null; + 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; + 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); + + // 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. + Dictionary deviceExtensionsAvailable = EnumerateDeviceExtensions(); + bool checkpointsDisabled = + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_CHECKPOINTS") is "0" or "off" or "false"; + bool wantCheckpoints = !checkpointsDisabled && IntPtr.Size == 8 + && deviceExtensionsAvailable.ContainsKey("VK_NV_device_diagnostic_checkpoints"); + + var faultFeatures = new PhysicalDeviceFaultFeaturesEXT + { + SType = StructureType.PhysicalDeviceFaultFeaturesExt, + }; + bool wantDeviceFault = false; + if (deviceExtensionsAvailable.ContainsKey("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, + 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, + SamplerAnisotropy = available.SamplerAnisotropy, + DepthClamp = available.DepthClamp, + ShaderClipDistance = available.ShaderClipDistance, + 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.ContainsKey("VK_EXT_color_write_enable"); + bool hasDynamicState3 = deviceExtensionsAvailable.ContainsKey("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, + ColorWriteEnable = true, + }; + dynamicState3Features = new PhysicalDeviceExtendedDynamicState3FeaturesEXT + { + SType = StructureType.PhysicalDeviceExtendedDynamicState3FeaturesExt, + ExtendedDynamicState3ColorWriteMask = true, + ExtendedDynamicState3ColorBlendEnable = canBlend, + ExtendedDynamicState3ColorBlendEquation = canBlend, + }; + + // 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, + DynamicRendering = true, + Synchronization2 = true, + PipelineCreationCacheControl = pipelineCacheControl, + }; + var vulkan12 = new PhysicalDeviceVulkan12Features + { + SType = StructureType.PhysicalDeviceVulkan12Features, + 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 + { + SType = StructureType.PhysicalDeviceFeatures2, + PNext = &vulkan12, + Features = enabledFeatures, + }; + + 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"); + + // 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.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; + + 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); + 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; + 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; + EnabledDeviceExtensions = deviceExtensions.ToArray(); + RecordLatencyCapabilities(); + Allocator = new VulkanAllocator(this); + return true; + } + + /// + /// 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; + + 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[name] = propertiesPtr[i].SpecVersion; + } + } + 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 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); + 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", + 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, + WideLines = features.WideLines, + LineWidthMin = properties.Limits.LineWidthRange[0], + LineWidthMax = properties.Limits.LineWidthRange[1], + FillModeNonSolid = features.FillModeNonSolid, + SamplerAnisotropy = features.SamplerAnisotropy, + MultiDrawIndirect = features.MultiDrawIndirect, + OcclusionQueryPrecise = features.OcclusionQueryPrecise, + 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), + }; + } + + 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) + { + VulkanStats.WaitDeviceIdle(Api, 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); + } + + 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/VulkanPoison.cs b/Optimum.Render.Vulkan/Core/VulkanPoison.cs new file mode 100644 index 00000000..5431f853 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanPoison.cs @@ -0,0 +1,74 @@ +using System; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// The values poison mode (OPTIMUM_VULKAN_POISON=1) writes into fresh resources. +/// +/// OpenGL and Vulkan both leave new storage undefined, but in practice GL +/// drivers hand out zeroed memory and Vulkan allocators hand out whatever the +/// previous tenant left. A read of never-written content therefore "works" on +/// one backend and flickers on the other. Poison makes such a read loud and +/// identical every frame: NaN for float formats, magenta (alpha 1) for +/// normalised and sRGB colour, 0xDEADBEEF for integer formats and host memory, +/// 0.5 for depth. +/// +internal static unsafe class VulkanPoison +{ + public const uint Word = 0xDEADBEEF; + public const float Depth = 0.5f; + + public static bool IsCompressed(Format format) => + format.ToString().Contains("Block", StringComparison.Ordinal); + + public static bool IsFloat(Format format) + { + string name = format.ToString(); + return name.Contains("Sfloat", StringComparison.Ordinal) || name.Contains("Ufloat", StringComparison.Ordinal); + } + + public static bool IsInteger(Format format) + { + string name = format.ToString(); + return name.Contains("Uint", StringComparison.Ordinal) || name.Contains("Sint", StringComparison.Ordinal); + } + + public static ClearColorValue ColorFor(Format format) + { + var value = new ClearColorValue(); + if (IsFloat(format)) + { + value.Float32_0 = float.NaN; + value.Float32_1 = float.NaN; + value.Float32_2 = float.NaN; + value.Float32_3 = float.NaN; + } + else if (IsInteger(format)) + { + // Uint and Sint clears read the same union bits. + value.Uint32_0 = Word; + value.Uint32_1 = Word; + value.Uint32_2 = Word; + value.Uint32_3 = Word; + } + else + { + value.Float32_0 = 1f; + value.Float32_1 = 0f; + value.Float32_2 = 1f; + value.Float32_3 = 1f; + } + return value; + } + + /// Writes 0xDEADBEEF as little-endian words over the whole range, a partial word at the tail. + public static void FillHostMemory(IntPtr memory, ulong size) + { + byte* bytes = (byte*)memory; + ulong words = size / 4; + uint* wordPointer = (uint*)bytes; + for (ulong i = 0; i < words; i++) wordPointer[i] = Word; + for (ulong i = words * 4; i < size; i++) bytes[i] = (byte)(Word >> (int)(8 * (i % 4))); + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanResources.cs b/Optimum.Render.Vulkan/Core/VulkanResources.cs new file mode 100644 index 00000000..6dc22555 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanResources.cs @@ -0,0 +1,318 @@ +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; + +/// +/// 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); + + /// 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. +internal sealed unsafe class VulkanBuffer : IDisposable +{ + private readonly VulkanContext _context; + private bool _disposed; + + 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(); + + 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; } + + /// + /// The frame command buffer generation that last used this buffer; see + /// . + /// + 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; + Usage = usage; + + 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; + + 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", poolClass, dedicated, buffer, default); + + api.BindBufferMemory(context.Device, buffer, _allocation.Memory, _allocation.Offset); + Mapped = _allocation.Mapped; + if (context.PoisonFreshResources && Mapped != IntPtr.Zero) + { + VulkanPoison.FillHostMemory(Mapped, size); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + // 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); + } +} + +/// 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 ImageView View { get; } + + private MemoryAllocation _allocation; + 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; + + 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", + MemoryPoolClass.DeviceImages, dedicated, default, image); + api.BindImageMemory(context.Device, image, _allocation.Memory, _allocation.Offset); + + 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); + _context.Allocator.Free(_allocation); + } +} + +/// +/// 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; + + /// + /// 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; + + 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; + + 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); + } +} + +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(); + VulkanStats.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}"); + } +} diff --git a/Optimum.Render.Vulkan/Core/VulkanStats.cs b/Optimum.Render.Vulkan/Core/VulkanStats.cs new file mode 100644 index 00000000..ab1dd37a --- /dev/null +++ b/Optimum.Render.Vulkan/Core/VulkanStats.cs @@ -0,0 +1,1024 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Threading; +using Silk.NET.Vulkan; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Every place the backend makes the CPU wait on the GPU or the presentation +/// engine. Fixed and small so the counters are two flat arrays; the order is the +/// order of the tokens on the stats.waits line. +/// +internal enum WaitSite +{ + /// + /// vkWaitSemaphores on the Frame timeline for value n - FramesInFlight at the + /// start of frame n; exactly one per frame start, the ring's only steady-state wait. + /// + FramePacing = 0, + /// + /// 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: + /// readbacks and uploads submit partially without waiting and queries never + /// flush, so this stays zero; the token stays for log compatibility. + /// + FlushFrame = 2, + /// vkDeviceWaitIdle, wherever it is called. + DeviceWaitIdle = 3, + /// + /// A readback the caller needs now: the Frame timeline value of the partial + /// submission that carried the copy, or a between-frames setup fence. + /// + Readback = 4, + /// + /// Polling an occlusion query until its result is available. Retired in + /// Phase 1B (QueryRing reads results without waiting); stays zero. + /// + OcclusionQuery = 5, + /// vkAcquireNextImageKHR. + SwapchainAcquire = 6, + /// vkQueuePresentKHR, including the queue lock. + Present = 7, + /// + /// vkQueueSubmit of a frame slot, including the queue lock. A worker's + /// synchronous upload holds that lock through its fence wait, so the render + /// 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, +} + +/// +/// 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. +/// +/// One sample is four lines. The first is the original human-readable line and +/// keeps its format byte for byte (older logs and readers depend on it); the +/// other three carry stable key=value tokens for scripts +/// (scripts/dev/pacing-gate.sh): +/// +/// stats 1.0s: 60 frames (16.7 ms/frame), ... +/// 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 +{ + /// Token stems of , indexed by its value. + public static readonly string[] WaitSiteTokens = + { + "frame_pacing", + "upload_submit", + "flush_frame", + "device_wait_idle", + "readback", + "occlusion_query", + "swapchain_acquire", + "present", + "queue_submit", + "latency_sleep", + }; + + public const int WaitSiteCount = 10; + + /// + /// 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; + + 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; + private static long _uniformOverflows; + + private static long _blockingUploads; + 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; + private static long _uniformRingCapacity; + private static readonly long[] _waitCounts = new long[WaitSiteCount]; + private static readonly long[] _waitTicks = new long[WaitSiteCount]; + + /// CPU frame intervals of the last 512 frames, any device. + public static readonly FrameIntervalRing FrameIntervals = new(FrameIntervalRing.DefaultCapacity); + + /// 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); + public static void NoteFrame() => Interlocked.Increment(ref _frames); + + /// Textures deleted since the last . + public static long TexturesDeleted => Interlocked.Read(ref _texturesDeleted); + + /// + /// A synchronous setup submission of any kind (uploads and readbacks alike). + /// Feeds the original line's "blocking uploads" figure, whose meaning is kept. + /// + public static void NoteUpload(long elapsedTicks) + { + Interlocked.Increment(ref _uploads); + Interlocked.Add(ref _uploadWaitTicks, elapsedTicks); + } + + /// A texture upload or mip generation was requested, whether or not it waited. + public static void NoteUploadRequest() => Interlocked.Increment(ref _uploadRequests); + + /// An upload that really waited on a fence or the queue lock. + public static void NoteBlockingUpload() => Interlocked.Increment(ref _blockingUploads); + + 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); + + 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); + + 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); + + 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; + 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); + + /// Image memory barriers recorded into a command buffer. + public static void NoteImageBarriers(int count) => Interlocked.Add(ref _imageBarriers, count); + + 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); + + 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); + 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); + } + + 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); + + 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() + { + 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); + 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); + + 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); + + /// + /// Uniform-ring bytes one frame slot used by the time it was submitted, and + /// that slot's capacity. The sample reports the peak since the last sample. + /// + public static void NoteUniformRingUse(ulong used, ulong capacity) + { + long value = (long)Math.Min(used, long.MaxValue); + long peak = Interlocked.Read(ref _uniformRingPeak); + while (value > peak) + { + long seen = Interlocked.CompareExchange(ref _uniformRingPeak, value, peak); + if (seen == peak) break; + peak = seen; + } + Interlocked.Exchange(ref _uniformRingCapacity, (long)Math.Min(capacity, long.MaxValue)); + } + + public static long UniformRingPeak => Interlocked.Read(ref _uniformRingPeak); + + /// A timestamp to hand to once the wait returns. + public static long WaitStart() => Stopwatch.GetTimestamp(); + + /// Counts one wait at that began at . + public static void NoteWait(WaitSite site, long startTimestamp) + { + int index = (int)site; + Interlocked.Increment(ref _waitCounts[index]); + Interlocked.Add(ref _waitTicks[index], Stopwatch.GetTimestamp() - startTimestamp); + } + + public static long WaitCount(WaitSite site) => Interlocked.Read(ref _waitCounts[(int)site]); + + public static double WaitMilliseconds(WaitSite site) => + Interlocked.Read(ref _waitTicks[(int)site]) * 1000.0 / Stopwatch.Frequency; + + /// vkDeviceWaitIdle, counted at . Every call site goes through here. + public static Result WaitDeviceIdle(Vk api, Device device) + { + long start = Stopwatch.GetTimestamp(); + Result result = api.DeviceWaitIdle(device); + NoteWait(WaitSite.DeviceWaitIdle, start); + return result; + } + + /// One CPU frame interval (start of a frame to start of the next), in milliseconds. + public static void NoteFrameInterval(double milliseconds) => FrameIntervals.Add(milliseconds); + + /// + /// Takes and clears the counters, formatted as one sample (four lines joined + /// by '\n', no trailing newline), 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); + long overflows = Interlocked.Exchange(ref _uniformOverflows, 0); + + var waitCounts = new long[WaitSiteCount]; + var waitMs = new double[WaitSiteCount]; + for (int i = 0; i < WaitSiteCount; i++) + { + waitCounts[i] = Interlocked.Exchange(ref _waitCounts[i], 0); + waitMs[i] = Interlocked.Exchange(ref _waitTicks[i], 0) * 1000.0 / Stopwatch.Frequency; + } + + var counters = new CounterSample( + BlockingUploads: Interlocked.Exchange(ref _blockingUploads, 0), + Uploads: Interlocked.Exchange(ref _uploadRequests, 0), + Scopes: Interlocked.Exchange(ref _scopesOpened, 0), + Barriers: Interlocked.Exchange(ref _imageBarriers, 0), + RebarFallbacks: Interlocked.Exchange(ref _rebarFallbacks, 0), + DynamicState: Interlocked.Exchange(ref _dynamicStateCommands, 0), + UniformRingUsed: Interlocked.Exchange(ref _uniformRingPeak, 0), + UniformRingCapacity: Interlocked.Read(ref _uniformRingCapacity), + BarrierCommands: Interlocked.Exchange(ref _barrierCommands, 0), + Frames: frames, + MaskRestarts: Interlocked.Exchange(ref _maskRestarts, 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), + PushConstantWrites: Interlocked.Exchange(ref _intervalPushConstantWrites, 0), + StorageSetBinds: Interlocked.Exchange(ref _intervalStorageSetBinds, 0), + BindlessSlots: Interlocked.Exchange(ref _intervalBindlessSlots, 0), + BindlessPlaceholders: Interlocked.Exchange(ref _intervalBindlessPlaceholders, 0), + ComputePasses: Interlocked.Exchange(ref _computePasses, 0), + Dispatches: Interlocked.Exchange(ref _dispatches, 0), + NativePasses: Interlocked.Exchange(ref _intervalNativePasses, 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; + + 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) + "\n" + + LatencyLine(waitCounts[(int)WaitSite.LatencySleep], waitMs[(int)WaitSite.LatencySleep]) + "\n" + + 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))) + "\n" + + 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); + + /// + /// 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. + /// + 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) + { + 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}, uniform overflows {11}", + elapsed, frames, frameMs, allocations, liveAllocations, + uploads, uploadMs, uploadMs / (elapsed * 1000.0) * 100.0, created, deleted, dropped, overflows); + } + + public static string FormatPacingLine(FramePacingSnapshot pacing) => + string.Format(CultureInfo.InvariantCulture, + "stats.pacing samples={0} p50_ms={1:F3} p95_ms={2:F3} p99_ms={3:F3} stddev_ms={4:F3} stutters={5}", + pacing.Samples, pacing.P50, pacing.P95, pacing.P99, pacing.StdDev, pacing.Stutters); + + public static string FormatWaitsLine(long[] counts, double[] milliseconds) + { + var line = new StringBuilder("stats.waits"); + for (int i = 0; i < WaitSiteCount; i++) + { + line.Append(' ').Append(WaitSiteTokens[i]).Append("_n=") + .Append(counts[i].ToString(CultureInfo.InvariantCulture)); + line.Append(' ').Append(WaitSiteTokens[i]).Append("_ms=") + .Append(milliseconds[i].ToString("F1", CultureInfo.InvariantCulture)); + } + return line.ToString(); + } + + 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} " + + "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_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.NativeFullscreenDraws, counters.NativeMeshDraws, counters.NativeInstancedDraws, + counters.NativeIndirectDraws); + + 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, + long Uploads, + long Scopes, + long Barriers, + long RebarFallbacks, + long DynamicState, + long UniformRingUsed, + long UniformRingCapacity, + long BarrierCommands = 0, + long Frames = 0, + long MaskRestarts = 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, + long PushConstantWrites = 0, + long StorageSetBinds = 0, + long BindlessSlots = 0, + long BindlessPlaceholders = 0, + long ComputePasses = 0, + long Dispatches = 0, + long NativePasses = 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( + 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); + +/// +/// The last N CPU frame intervals, for p50/p95/p99, standard deviation and the +/// stutter count (intervals above 2 x p50). +/// +/// Both arrays are allocated once; adding a frame is a store under a lock, and a +/// snapshot sorts into the preallocated scratch array, so neither allocates. +/// Percentiles are nearest-rank (index ceil(p * n) - 1), the same rule the +/// client's OPTIMUM_FPS_LOG uses for p99, so the two logs are comparable. +/// +internal sealed class FrameIntervalRing +{ + public const int DefaultCapacity = 512; + + private readonly double[] _values; + private readonly double[] _scratch; + private readonly object _lock = new(); + private int _next; + private int _count; + + public FrameIntervalRing(int capacity) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _values = new double[capacity]; + _scratch = new double[capacity]; + } + + public int Capacity => _values.Length; + + public int Count + { + get { lock (_lock) return _count; } + } + + public void Add(double milliseconds) + { + if (!(milliseconds >= 0) || double.IsInfinity(milliseconds)) return; + lock (_lock) + { + _values[_next] = milliseconds; + _next = (_next + 1) % _values.Length; + if (_count < _values.Length) _count++; + } + } + + public FramePacingSnapshot Snapshot() + { + lock (_lock) + { + int n = _count; + if (n == 0) return default; + + // The live values are the first n slots until the ring wraps, and all + // of them afterwards; order does not matter once they are sorted. + Array.Copy(_values, _scratch, n); + Array.Sort(_scratch, 0, n); + + double sum = 0; + for (int i = 0; i < n; i++) sum += _scratch[i]; + double mean = sum / n; + double squares = 0; + for (int i = 0; i < n; i++) + { + double delta = _scratch[i] - mean; + squares += delta * delta; + } + + double p50 = NearestRank(_scratch, n, 0.50); + int stutters = 0; + for (int i = n - 1; i >= 0 && _scratch[i] > 2.0 * p50; i--) stutters++; + + return new FramePacingSnapshot(n, p50, NearestRank(_scratch, n, 0.95), + NearestRank(_scratch, n, 0.99), Math.Sqrt(squares / n), stutters); + } + } + + private static double NearestRank(double[] sorted, int n, double percentile) + { + int index = (int)Math.Ceiling(percentile * n) - 1; + if (index < 0) index = 0; + if (index > n - 1) index = n - 1; + return sorted[index]; + } +} diff --git a/Optimum.Render.Vulkan/Core/WindowSurface.cs b/Optimum.Render.Vulkan/Core/WindowSurface.cs new file mode 100644 index 00000000..dbf5c8e9 --- /dev/null +++ b/Optimum.Render.Vulkan/Core/WindowSurface.cs @@ -0,0 +1,100 @@ +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. + /// + /// + /// 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) + { + 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/Frame/FrameTimeline.cs b/Optimum.Render.Vulkan/Frame/FrameTimeline.cs new file mode 100644 index 00000000..f7f42042 --- /dev/null +++ b/Optimum.Render.Vulkan/Frame/FrameTimeline.cs @@ -0,0 +1,206 @@ +using System; +using System.Threading; +using Silk.NET.Vulkan; + +using Semaphore = Silk.NET.Vulkan.Semaphore; + +// The Frame/ folder follows the plan's layout; the namespace stays Core until the +// renderer is reorganised, so every existing consumer keeps its one using. +namespace Optimum.Render.Vulkan.Core; + +/// +/// The four numbers a needs from the timelines. An +/// interface so the lifetime rules can be tested without a device. +/// +internal interface ITimelineClock +{ + /// + /// The newest Frame value any command recorded or submitted so far carries: + /// a resource released now can only be referenced by work at or below it. + /// + ulong FrameRecorded { get; } + + /// The same for the Transfer timeline. + ulong TransferRecorded { get; } + + /// The Frame value the GPU has finished (the semaphore's counter). + ulong FrameCompleted { get; } + + /// The Transfer value the GPU has finished. + ulong TransferCompleted { get; } +} + +/// +/// The renderer's clock: two timeline semaphores. +/// +/// Every graphics submission of frame n signals to +/// n; transfer work signals . Pacing waits on the +/// Frame timeline, and deferred destruction compares recorded values against the +/// counters, so nothing in steady state needs a fence. +/// +/// Values are reserved before recording () and noted as +/// signalled once the submit that carries them was accepted +/// (). Waits are clamped to the signalled value: +/// waiting for a value no submission will ever signal would hang forever. +/// +/// Presentation cannot wait on a timeline, so the per-image present semaphores +/// stay binary and live in the swapchain. +/// +internal sealed unsafe class FrameTimeline : ITimelineClock, IDisposable +{ + private readonly VulkanContext _context; + private long _frameReserved; + private long _frameSignalled; + private long _transferReserved; + private long _transferSignalled; + private bool _disposed; + + public Semaphore Frame { get; } + public Semaphore Transfer { get; } + + public FrameTimeline(VulkanContext context) + { + _context = context; + Frame = CreateTimeline(context, "the Frame timeline semaphore"); + Transfer = CreateTimeline(context, "the Transfer timeline semaphore"); + } + + private static Semaphore CreateTimeline(VulkanContext context, string what) + { + var type = new SemaphoreTypeCreateInfo + { + SType = StructureType.SemaphoreTypeCreateInfo, + SemaphoreType = SemaphoreType.Timeline, + InitialValue = 0, + }; + var info = new SemaphoreCreateInfo + { + SType = StructureType.SemaphoreCreateInfo, + PNext = &type, + }; + Semaphore semaphore; + VulkanResult.Check(context.Api.CreateSemaphore(context.Device, &info, null, &semaphore), + "vkCreateSemaphore for " + what); + return semaphore; + } + + // ------------------------------------------------------------------ Frame + + /// The value the next frame's submission will signal. Render thread. + public ulong ReserveFrame() => (ulong)Interlocked.Increment(ref _frameReserved); + + /// The submission carrying was accepted by the queue. + public void NoteFrameSubmitted(ulong value) => RaiseTo(ref _frameSignalled, value); + + public ulong FrameRecorded => (ulong)Interlocked.Read(ref _frameReserved); + + /// The newest Frame value handed to an accepted submission. + public ulong FrameSignalled => (ulong)Interlocked.Read(ref _frameSignalled); + + public ulong FrameCompleted => Counter(Frame, "the Frame timeline"); + + /// + /// Blocks until the GPU finished Frame value (clamped + /// to what was signalled) and counts one wait at , even + /// when the value has already passed: the count is the number of pacing points, + /// which is what the stats gate compares. + /// + public void WaitForFrame(ulong value, WaitSite site) => + Wait(Frame, WaitTarget(value, FrameSignalled), site, "the Frame timeline"); + + /// + /// Teardown only: waits for every signalled frame before the caller destroys + /// what those frames name, and never throws (a lost device or an exception + /// 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() => WaitAtTeardown(Frame, FrameSignalled); + + /// The Transfer timeline's counterpart of . + public void WaitForSignalledTransfersAtTeardown() => WaitAtTeardown(Transfer, TransferSignalled); + + private void WaitAtTeardown(Semaphore semaphore, ulong signalled) + { + Semaphore handle = semaphore; + ulong target = signalled; + var info = new SemaphoreWaitInfo + { + SType = StructureType.SemaphoreWaitInfo, + SemaphoreCount = 1, + PSemaphores = &handle, + PValues = &target, + }; + long waitStart = VulkanStats.WaitStart(); + _context.Api.WaitSemaphores(_context.Device, &info, 5UL * 1000 * 1000 * 1000); + VulkanStats.NoteWait(WaitSite.DeviceWaitIdle, waitStart); + } + + // --------------------------------------------------------------- Transfer + + /// The value the next transfer submission will signal. + public ulong ReserveTransfer() => (ulong)Interlocked.Increment(ref _transferReserved); + + public void NoteTransferSubmitted(ulong value) => RaiseTo(ref _transferSignalled, value); + + public ulong TransferRecorded => (ulong)Interlocked.Read(ref _transferReserved); + + public ulong TransferSignalled => (ulong)Interlocked.Read(ref _transferSignalled); + + public ulong TransferCompleted => Counter(Transfer, "the Transfer timeline"); + + public void WaitForTransfer(ulong value, WaitSite site) => + Wait(Transfer, WaitTarget(value, TransferSignalled), site, "the Transfer timeline"); + + // ------------------------------------------------------------------ rules + + /// Never wait past the newest signalled value; nothing would ever wake the wait. + public static ulong WaitTarget(ulong requested, ulong signalled) => Math.Min(requested, signalled); + + // ---------------------------------------------------------------- helpers + + private ulong Counter(Semaphore semaphore, string what) + { + ulong value; + VulkanResult.Check(_context.Api.GetSemaphoreCounterValue(_context.Device, semaphore, &value), + "vkGetSemaphoreCounterValue on " + what); + return value; + } + + private void Wait(Semaphore semaphore, ulong value, WaitSite site, string what) + { + Semaphore handle = semaphore; + ulong target = value; + var info = new SemaphoreWaitInfo + { + SType = StructureType.SemaphoreWaitInfo, + SemaphoreCount = 1, + PSemaphores = &handle, + PValues = &target, + }; + + long waitStart = VulkanStats.WaitStart(); + Result result = _context.Api.WaitSemaphores(_context.Device, &info, ulong.MaxValue); + VulkanStats.NoteWait(site, waitStart); + VulkanResult.Check(result, "vkWaitSemaphores on " + what); + } + + private static void RaiseTo(ref long field, ulong value) + { + long wanted = (long)Math.Min(value, long.MaxValue); + long seen = Interlocked.Read(ref field); + while (wanted > seen) + { + long previous = Interlocked.CompareExchange(ref field, wanted, seen); + if (previous == seen) break; + seen = previous; + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _context.Api.DestroySemaphore(_context.Device, Frame, null); + _context.Api.DestroySemaphore(_context.Device, Transfer, null); + } +} diff --git a/Optimum.Render.Vulkan/Frame/IndirectRing.cs b/Optimum.Render.Vulkan/Frame/IndirectRing.cs new file mode 100644 index 00000000..8d711ba4 --- /dev/null +++ b/Optimum.Render.Vulkan/Frame/IndirectRing.cs @@ -0,0 +1,137 @@ +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) + { + // 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; + 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/Frame/QueryRing.cs b/Optimum.Render.Vulkan/Frame/QueryRing.cs new file mode 100644 index 00000000..5089ac61 --- /dev/null +++ b/Optimum.Render.Vulkan/Frame/QueryRing.cs @@ -0,0 +1,421 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; + +// The Frame/ folder follows the plan's layout; the namespace stays Core until the +// renderer is reorganised. +namespace Optimum.Render.Vulkan.Core; + +/// +/// Occlusion queries that never make the CPU wait. +/// +/// 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, 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 +/// the game polls after that point, or at the latest when the slot is recycled, +/// 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. +/// +internal sealed unsafe class QueryRing : IDisposable +{ + public const uint QueriesPerPool = 32; + + private const QueryResultFlags ReadFlags = QueryResultFlags.Result64Bit | QueryResultFlags.ResultWithAvailabilityBit; + private const int Stride = 2 * sizeof(ulong); + + private readonly VulkanContext _context; + private readonly ITimelineClock _clock; + private readonly SlotQueries[] _slots; + 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) + { + _context = context; + _clock = clock; + _slots = new SlotQueries[framesInFlight]; + for (int i = 0; i < framesInFlight; i++) _slots[i] = new SlotQueries(); + } + + private sealed class SlotQueries + { + public readonly List Pools = new(); + /// 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; + /// Records of the current generation not yet harvested. + public readonly List Pending = new(); + /// Value and availability per query, two ulongs each, in pool order. + public ulong[] Host = Array.Empty(); + } + + private sealed class QueryRecord + { + public readonly int Slot; + public readonly ulong Generation; + /// 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) + { + Slot = slot; + Generation = generation; + } + } + + private sealed class QueryObject + { + public QueryRecord? Active; + public QueryRecord? Latest; + /// The result of an earlier query, returned while the latest one is still in flight. + public int PreviousResult = int.MaxValue; + } + + /// Pools created so far across every slot. Tests only. + internal int PoolCount + { + get + { + int count = 0; + foreach (SlotQueries slot in _slots) count += slot.Pools.Count; + return count; + } + } + + public int Create() + { + int id = _nextId++; + _objects[id] = new QueryObject(); + return id; + } + + /// + /// 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. A query + /// still running ends with its scope and is not resumed. + /// + 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: + /// reads every result that frame produced into the host buffer, then records + /// the pool resets at the top of the new command buffer. + /// + public void BeginSlot(int slotIndex, CommandBuffer commandBuffer) + { + SlotQueries slot = _slots[slotIndex]; + Harvest(slot); + + uint poolsUsed = (slot.Used + QueriesPerPool - 1) / QueriesPerPool; + for (int i = 0; i < poolsUsed; i++) + { + _context.Api.CmdResetQueryPool(commandBuffer, slot.Pools[i], 0, QueriesPerPool); + } + + 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; + + /// + /// 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 void AddPool(CommandBuffer commandBuffer) + { + SlotQueries slot = _slots[_currentSlot]; + 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; + + _context.Api.CmdResetQueryPool(commandBuffer, created, 0, 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; + + SlotQueries slot = _slots[_currentSlot]; + var record = new QueryRecord(_currentSlot, slot.Generation); + slot.Pending.Add(record); + _objects[id].Active = record; + + if (scopeOpen) Start(record, commandBuffer); + else _suspended = record; + } + + /// + /// 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 void End(int id, ulong frameValue, CommandBuffer commandBuffer) + { + if (!_objects.TryGetValue(id, out QueryObject? query) || query.Active == null) return; + + QueryRecord record = query.Active; + query.Active = null; + 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 != _slots[record.Slot].Generation) return; + + if (query.Latest != null) + { + Refresh(query.Latest); + if (query.Latest.Resolved) query.PreviousResult = Clamp(query.Latest.Samples); + } + + 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; + } + + 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. + public bool IsResultAvailable(int id) + { + if (!_objects.TryGetValue(id, out QueryObject? query) || query.Latest == null) return false; + Refresh(query.Latest); + return query.Latest.Resolved; + } + + /// + /// The latest query's sample count once available; before that, the previous + /// query's, and "every sample passed" when there never was one - for a query + /// that gates culling or glare, visible is the failure that costs little. + /// + public int GetResult(int id) + { + if (!_objects.TryGetValue(id, out QueryObject? query)) return 0; + if (query.Latest != null) + { + Refresh(query.Latest); + if (query.Latest.Resolved) return Clamp(query.Latest.Samples); + } + return query.PreviousResult; + } + + private static int Clamp(ulong samples) => (int)Math.Min(samples, int.MaxValue); + + /// Reads one result early, once the timeline has passed the command buffer that carried it. + private void Refresh(QueryRecord record) + { + if (record.Resolved || !record.Ended) return; + SlotQueries slot = _slots[record.Slot]; + // Harvest resolves every record of a generation before bumping it. + if (record.Generation != slot.Generation) return; + if (_clock.FrameCompleted < record.FrameValue) return; + + if (record.Lost || record.Indices.Count == 0) + { + record.Resolved = true; + record.Samples = ulong.MaxValue; + return; + } + + ulong samples = 0; + fixed (ulong* host = slot.Host) + { + foreach (uint index in record.Indices) + { + 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]; + } + } + + record.Resolved = true; + record.Samples = samples; + } + + private void Harvest(SlotQueries slot) + { + if (slot.Pending.Count == 0) return; + + uint remaining = slot.Used; + fixed (ulong* host = slot.Host) + { + for (int p = 0; remaining > 0 && p < slot.Pools.Count; p++) + { + uint count = Math.Min(remaining, QueriesPerPool); + Result status = _context.Api.GetQueryPoolResults(_context.Device, slot.Pools[p], 0, count, + (nuint)(count * Stride), host + (ulong)p * QueriesPerPool * 2, (ulong)Stride, ReadFlags); + // NOT_READY only means some query was never ended; the availability + // word says which, and the rest are written. + if (status != Result.Success && status != Result.NotReady) + { + VulkanResult.Check(status, "vkGetQueryPoolResults for the occlusion query ring"); + } + remaining -= count; + } + } + + foreach (QueryRecord record in slot.Pending) + { + if (record.Resolved) continue; + record.Resolved = true; + 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() + { + if (_disposed) return; + _disposed = true; + foreach (SlotQueries slot in _slots) + { + foreach (QueryPool pool in slot.Pools) _context.Api.DestroyQueryPool(_context.Device, pool, null); + slot.Pools.Clear(); + } + _objects.Clear(); + } +} diff --git a/Optimum.Render.Vulkan/Frame/RetireQueue.cs b/Optimum.Render.Vulkan/Frame/RetireQueue.cs new file mode 100644 index 00000000..427ee68b --- /dev/null +++ b/Optimum.Render.Vulkan/Frame/RetireQueue.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace Optimum.Render.Vulkan.Core; + +/// +/// Resources waiting for the GPU to stop referencing them. +/// +/// Each entry records the newest Frame and Transfer values that existed when it +/// was retired: any command that could name the resource carries one of those +/// values or an older one. The entry is destroyed at the first +/// that sees both counters at or past its values, never +/// earlier, and ready entries are destroyed in the order they were retired. +/// +/// is safe from any thread (the game's VAO and UBO +/// finalizers release from the finalizer thread). runs on +/// the render thread at the start of a frame, and disposes outside the lock so a +/// resource whose Dispose retires something else cannot deadlock. +/// +internal sealed class RetireQueue +{ + private readonly record struct Entry(IDisposable Resource, ulong Frame, ulong Transfer); + + private readonly ITimelineClock _clock; + private readonly object _lock = new(); + private readonly List _entries = new(); + private int _count; + + public RetireQueue(ITimelineClock clock) => _clock = clock; + + /// Entries not yet destroyed. + public int PendingCount => Volatile.Read(ref _count); + + /// Queues against the timeline values recorded right now. + public void Retire(IDisposable resource) + { + lock (_lock) + { + _entries.Add(new Entry(resource, _clock.FrameRecorded, _clock.TransferRecorded)); + Volatile.Write(ref _count, _entries.Count); + } + } + + /// + /// Destroys every entry whose Frame and Transfer values have both completed, + /// oldest first. An entry that has not passed stays queued without holding back + /// later entries that have. Returns how many were destroyed. + /// + public int Collect() + { + if (PendingCount == 0) return 0; + + // Completion only moves forward, so counters read before taking the lock + // are still true for every entry inside it. + ulong frameCompleted = _clock.FrameCompleted; + ulong transferCompleted = _clock.TransferCompleted; + + List? ready = null; + lock (_lock) + { + int kept = 0; + for (int i = 0; i < _entries.Count; i++) + { + Entry entry = _entries[i]; + if (entry.Frame <= frameCompleted && entry.Transfer <= transferCompleted) + { + ready ??= new List(); + ready.Add(entry.Resource); + } + else + { + _entries[kept++] = entry; + } + } + _entries.RemoveRange(kept, _entries.Count - kept); + Volatile.Write(ref _count, _entries.Count); + } + + if (ready == null) return 0; + foreach (IDisposable resource in ready) resource.Dispose(); + return ready.Count; + } + + /// + /// Destroys everything regardless of the timelines, oldest first. Teardown + /// only, after the GPU has finished all submitted work. + /// + public void DisposeAll() + { + while (true) + { + Entry[] all; + lock (_lock) + { + if (_entries.Count == 0) return; + all = _entries.ToArray(); + _entries.Clear(); + Volatile.Write(ref _count, 0); + } + foreach (Entry entry in all) entry.Resource.Dispose(); + } + } +} 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/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/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/FrameGraph.cs b/Optimum.Render.Vulkan/Graph/FrameGraph.cs new file mode 100644 index 00000000..281e81bf --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/FrameGraph.cs @@ -0,0 +1,338 @@ +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(); + + // 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; } + 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; } + public long ComputePasses { get; private set; } + public long Dispatches { get; private set; } + + /// Passes opened in the frame being recorded. + public int PassesThisFrame => _frame.Count; + + /// 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) + { + 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; + 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++; + 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=" + (PrefixMatchesPlan ? "match" : "conservative")); + } + 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 + /// while the frame so far matches the plan, LOAD otherwise. + /// + public AttachmentLoadOp PlannedLoad(int pass, int 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; + } + + /// 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) + { + bool hit = false; + foreach (FramePlan? plan in _plans) + { + hit |= plan != null && !plan.IsConservative && plan.Matches(_frame); + } + if (hit) + { + PlanHits++; + VulkanStats.NotePlanHit(); + } + else + { + PlanMisses++; + VulkanStats.NotePlanMiss(); + } + _plans[1] = _plans[0]; + _plans[0] = FramePlan.Build(_frame); + } + _frame.Clear(); + _prefixMatches[0] = true; + _prefixMatches[1] = 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/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/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/Graph/PassRecorder.cs b/Optimum.Render.Vulkan/Graph/PassRecorder.cs new file mode 100644 index 00000000..e555344e --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/PassRecorder.cs @@ -0,0 +1,280 @@ +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]; + 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; + } + // 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++; + } + + 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/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/ResourceStateTracker.cs b/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs new file mode 100644 index 00000000..3209f20b --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/ResourceStateTracker.cs @@ -0,0 +1,277 @@ +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; + } + + /// + /// 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. + /// 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..c796de38 --- /dev/null +++ b/Optimum.Render.Vulkan/Graph/ResourceUsage.cs @@ -0,0 +1,208 @@ +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, + /// 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. + 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.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, + 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), + // 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), + }; + + /// + /// 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/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/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); + } + } +} 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/Optimum.Render.Vulkan.csproj b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj new file mode 100644 index 00000000..9f2541be --- /dev/null +++ b/Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj @@ -0,0 +1,104 @@ + + + + + + net10.0 + Optimum.Render.Vulkan + Optimum.Render.Vulkan + true + ..\bin\$(Configuration) + annotations + + true + true + + + + + + + false + all + + + + + + + + + + + + + + + + + ..\.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 + + + ..\.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 + + + + + + + + + + + + + shaders-vk/gtao/%(Filename)%(Extension) + + + + 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 new file mode 100644 index 00000000..e39e0eb5 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/StatedRenderState.cs @@ -0,0 +1,164 @@ +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 = RenderLimits.MaxColorAttachments; + public const int MaxTextureUnits = RenderLimits.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); + } + + /// 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) + { + 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; + 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) => + _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.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.Frame.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs new file mode 100644 index 00000000..3968a6d2 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Frame.cs @@ -0,0 +1,151 @@ +using Optimum.Render.Vulkan.Core; +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 +{ + /// + /// 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() + { + // 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"; + passContextFlags = Graph.PassFlags.AllowSplit; + } + + /// + /// 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() + { + // GL leaves the probed width set, so the stated line width is 1.5 from here on too. + stated.LineWidth = 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.FrameBuffers.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs new file mode 100644 index 00000000..6179ba61 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.FrameBuffers.cs @@ -0,0 +1,729 @@ +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); + } + StateDrawBuffers(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); + StateDrawBuffers(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. + // 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); + 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. + Random random = new Random(5); + int noiseSize = 16; + float[] noise = BuildOptimumSsaoNoise(random, noiseSize); + GCHandle noiseHandle = GCHandle.Alloc(noise, GCHandleType.Pinned); + // 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(); + 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); + } + + // 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); + } + + // World/UI separation: the HUD-less scene snapshot and the UI image (UiSeparation.cs). + AllocateUiSeparationTargets(list, width, height); + + 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(); + StateDrawBuffers(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. Every caller is a post-chain + /// slot, so the colour texture is a transient (Transient pool class, registered with the + /// device's transient allocator; SetupDefaultFrameBuffers tags its slot number). + /// + 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, -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); + device.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0); + StateDrawBuffers(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); + StateDrawBuffers(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); + } + StateDrawBuffers(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. + stated.ForgetFramebuffer(frameBuffer.FboId); + 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) + { + // 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(); + for (int k = 0; k < buffers.Count; k++) + { + if (buffers[k] != null) + { + stated.ForgetFramebuffer(buffers[k].FboId); + 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) + { + // 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) + { + forkFramebuffer = 0; + } + + public override void ClearBoundFrameBuffer(FrameBufferRef framebuffer, float[] clearColor, bool clearDepthBuffer, bool clearColorBuffers) + { + if (clearColorBuffers) + { + for (int k = 0; k < framebuffer.ColorTextureIds.Length; k++) + { + ClearTargetColor(framebuffer.FboId, k, clearColor[0], clearColor[1], clearColor[2], clearColor[3]); + } + } + if (clearDepthBuffer) + { + ClearTargetDepth(framebuffer.FboId, 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) + { + int target = CurrentTargetId; + switch (framebuffer) + { + case EnumFrameBuffer.Default: + ClearTargetColor(target, 0, clearR, clearG, clearB, clearA); + ClearTargetDepth(target, 1f); + break; + case EnumFrameBuffer.Primary: + ClearTargetColor(target, 0, 0f, 0f, 0f, 1f); + ClearTargetColor(target, 1, 0f, 0f, 0f, 1f); + if (OptimumRenderSsao) + { + ClearTargetColor(target, 2, 0f, 0f, 0f, 1f); + ClearTargetColor(target, 3, 0f, 0f, 0f, 1f); + } + if (MotionAttachmentIndex >= 0) + { + // 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); + ClearTargetColor(target, MotionAttachmentIndex, 0f, 0f, 0f, 0f); + StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + } + ClearTargetDepth(target, 1f); + break; + case EnumFrameBuffer.LiquidDepth: + case EnumFrameBuffer.ShadowmapFar: + case EnumFrameBuffer.ShadowmapNear: + { + FrameBufferRef optimumTarget = FrameBuffers[(int)framebuffer]; + NoteForkViewport(0, 0, optimumTarget.Width, optimumTarget.Height); + 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. + ClearTargetColor(target, 0, 0f, 0f, 0f, 0f); + ClearTargetColor(target, 1, 1f, 0f, 0f, 0f); + ClearTargetColor(target, 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() + { + 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); + NoteNativeTransparentBlend(1, 32774, 0, 769, 0, 769); + NoteNativeTransparentBlend(2, 32774, 770, 771, 770, 771); + nativeTransparentSlots = 7; + } + + /// + /// 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) + { + stated.SetBlendEnabled(enabled); + statedBlendOn = 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() + { + GlDisableDepthTest(); + StateBlend(true, EnumBlendMode.Standard); + StateSlotBlendFunc(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() + { + ClearTargetColor(CurrentTargetId, 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() + { + StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 1); + GlDisableDepthTest(); + } + + public override void RestoreWorldDrawBuffers(bool ssaoAttachments) + { + if (ssaoAttachments) + { + StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 15); + } + else + { + StateDrawBuffers(CurrentFrameBuffer != null ? CurrentFrameBuffer.FboId : 0, 3); + } + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs new file mode 100644 index 00000000..1e28935a --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Graph.cs @@ -0,0 +1,376 @@ +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, 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. + 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?.EndStagePass(); + // 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: the prefix and flags of the passes recorded under it. + private void SetPassContext(string context, PassFlags flags) + { + passContext = context; + passContextFlags = flags; + } + + /// + /// 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 id = CurrentTargetId; + int index = FrameBufferIndexOf(id); + string target = index >= 0 + ? index.ToString(CultureInfo.InvariantCulture) + : id == PassDeclaration.DefaultFramebuffer + ? "Default" + : "fbo" + id.ToString(CultureInfo.InvariantCulture); + return passContext + "/" + target; + } + + 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); + // Absent motion attachment: the index stays -1. + if (MotionAttachmentIndex > -1) 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 + + /// + /// 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() + { + NotePostStep(NativePostStep.OitMerge); + if (UseNativePostChain) + { + NativeOitMerge(); + return; + } + LegacyOitMerge(); + } + + /// Phase 3b stage 1: the chain's second pass, drawn natively. + public override bool RenderOptimumSkyMotion() + { + 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); + } + + 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; + } + + /// + /// 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, 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(); + } + 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(); + } + + /// + /// 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() + { + NotePostStep(NativePostStep.Blit); + if (NativeBlitEnabled && UseNativePostChain) + { + RenderNativeBlit(); + } + 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.Leaf.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs new file mode 100644 index 00000000..4744f392 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Leaf.cs @@ -0,0 +1,243 @@ +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; + +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) + { + ClearTargetDepth(CurrentTargetId, 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. 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. + 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) + { + 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 + // 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); + nativeTransparentSlots = 0x3F; + 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. + public override void BindOitTextures(int revealTexture, int accumTexture) + { + stated.BindTexture(6, revealTexture); + stated.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 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: + /// 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.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); + } + + public override string GraphicsBackendName => device.BackendName; +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs new file mode 100644 index 00000000..4a5757aa --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs @@ -0,0 +1,258 @@ +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"); + } + // 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; + } + if (TryRenderMinimalGuiNative(modelRef)) + { + RuntimeStats.drawCallsCount--; // DrawNativeGuiMesh counted it already + return; + } + if (TryRenderCloudsNative(modelRef)) + { + RuntimeStats.drawCallsCount--; // the cloud pass counted it already + return; + } + if (TryRenderStandardMeshNative(modelRef)) return; + RuntimeStats.drawCallsCount--; // the stated route counts what it records + TryDrawStated(vAO, 1, null, null, 0); + } + + public override void RenderFullscreenTriangle(MeshRef modelRef) + { + // The post passes generate their three vertices in the shader, so the + // mesh carries no buffers and none are bound. + TryDrawStated(null, 1, null, null, 0); + } + + public override void RenderMesh(MeshRef modelRef, int[] indices, int[] indicesSizes, int groupCount, bool useSSBOs) + { + 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 generic stated draw. + 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; + + // 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) + { + RuntimeStats.drawCallsCount++; + VAO vAO = (VAO)modelRef; + if (TryRenderParticles2dNative(modelRef, quantity)) { RuntimeStats.drawCallsCount--; return; } + RuntimeStats.drawCallsCount--; + if (quantity > 0) TryDrawStated(vAO, quantity, null, null, 0); + } + + 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) + { + // 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(); + } + } + + /// + /// 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.ModPasses.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs new file mode 100644 index 00000000..6c0ee5c3 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.ModPasses.cs @@ -0,0 +1,295 @@ +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; + 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 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!; + stated.SetDrawBuffers(targetId, plan.Declaration.ColorSlots); + + bool motion = false; + CurrentModPass = plan.Declaration.Name; + statedPass = plan.Declaration; + 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; + statedPass = null; + stated.SetDrawBuffers(targetId, savedDrawBuffers); + } + } + + 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.NativeBlit.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs new file mode 100644 index 00000000..c9c0afe5 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeBlit.cs @@ -0,0 +1,261 @@ +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. 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, + AttachmentBlend[]? blend = null) + { + 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 = 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 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. + /// + 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/Platform/VulkanClientPlatform.NativeChunks.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs new file mode 100644 index 00000000..c8d5c1dc --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs @@ -0,0 +1,486 @@ +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 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"; + + /// + /// 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; + + /// + /// 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; + + // ------------------------------------------------------------------ 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 >= RenderLimits.MaxColorAttachments) return; + if (nativeTransparentBlend == null) + { + nativeTransparentBlend = new AttachmentBlend[RenderLimits.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(); + 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 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) + { + 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 = StatedViewport(); + 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 (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots; + 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.NativeClouds.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs new file mode 100644 index 00000000..be1d988e --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeClouds.cs @@ -0,0 +1,193 @@ +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 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 = + 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; + stated.DepthTest = enabled; + } + + internal void NoteForkBlend(bool enabled) + { + statedBlendOn = enabled; + stated.SetBlendEnabled(enabled); + } + + /// A cloud renderer's RenderMesh: the native pass, or false for the generic stated 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 (!IsRegistryProgram(program)) 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 = StatedViewport(); + 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(); + 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.NativeEntities.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs new file mode 100644 index 00000000..a1868de2 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeEntities.cs @@ -0,0 +1,348 @@ +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 . + /// + // 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"; + + private const string EntityShadowPass = "shadowmapentityanimated"; + + /// + /// The texture the client declared for a sampler, or 0 - which resolves to the placeholder. + /// The table itself is in + /// VulkanClientPlatform.NativeChunks.cs, filled by NoteNativeProgramTexture from + /// BindProgramTexture2D/Cube - 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. + /// + 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. + if (program.customSamplers.Count != 0 || program.clampTToEdge) return false; + + // 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) && + !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 - 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 && + ReferenceEquals(program, ShaderRegistry.getProgramByName(program.PassName)); + + 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 + /// 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.NativeGui.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs new file mode 100644 index 00000000..78e1265c --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeGui.cs @@ -0,0 +1,353 @@ +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 - 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 +// 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; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_GUI") != "0"; + + /// + /// 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 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); + } + } + + /// + /// 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 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 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 + /// 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) 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(pass, mesh, + DeclaredProgramTexture(program.ProgramId, "tex2d"), 0, + statedLineWidth, statedBlendOn, statedBlendMode, statedDepthTest, statedDepthWrite, + GlEnums.CompareOpFrom(statedDepthFunc), scissorEnabled ? statedScissor : null, label); + } + + 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 generic stated 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) + { + 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) + => DrawNativeGuiMesh(pass, mesh, textureId, overlayTextureId, lineWidth, blend, EnumBlendMode.Standard, + 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, CullModeFlags cull = CullModeFlags.None, int instanceCount = 1) + { + FrameBufferRef target = CurrentFrameBuffer; + ShaderProgramBase? program = ShaderProgramBase.CurrentShaderProgram; + var vao = mesh as VAO; + if (program == null || vao == null || vao.VaoId == 0 || vao.Disposed) 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); + // 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, framebufferId, blendMode), + DepthTest = depthTest, + DepthWrite = depthWrite, + DepthCompare = depthCompare, + Cull = cull, + Topology = device.NativeMeshTopology(vao.VaoId), + LineWidth = lineWidth, + }); + if (pipeline == null) return false; + + RuntimeStats.drawCallsCount++; + string outer = passContext; + PassFlags outerFlags = passContextFlags; + Rect2D viewport = StatedViewport(); + 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, + Scissor = scissor, + })) + { + 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.DrawNativeMeshInstanced(pipeline, vao.VaoId, instanceCount, textures); + } + device.EndNativePass(); + + // Whatever the stage was drawing into before this pass keeps drawing into it through + // the generic stated route, so its own pass context is restored - the same restore the + // sky pass and the TAA resolve do. + 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 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; + slots[i].WriteMask = 0; + } + return slots; + } +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs new file mode 100644 index 00000000..069cfd2a --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativePostChain.cs @@ -0,0 +1,678 @@ +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. +// +// 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 +// 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). + 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); + + /// + /// 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 + + /// + /// 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) => 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++) + { + if (((slots >> i) & 1) == 0) blend[i].WriteMask = 0; + else blend[i] = AttachmentBlend.Default; + } + 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. 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. + /// + 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. + 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; + } + + /// + /// 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) + { + if (UseNativePostChain && NativeAmbientOcclusionReady()) + { + NativeAmbientOcclusion(projectMatrix); + return; + } + OptimumPostAmbientOcclusion(projectMatrix); + } + + /// + /// 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(); + + /// Pass 5, the TAA sharpen; its draw seam is . + private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene); + + /// Pass 6, the bloom chain, drawn natively (VulkanClientPlatform.NativePostFinal.cs). + private void PostStepBloom(int scene, int glow) => NativeBloom(scene, glow); + + /// Pass 7, god rays, drawn natively. + private void PostStepGodRays(int scene, int glow) => NativeGodRays(scene, glow); + + /// Pass 8, the FXAA luma prepass or the pass-through blit into Luma, drawn natively. + private void PostStepFxaaOrBlit(int scene) => NativePostLuma(scene); + + /// + /// 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 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); + 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 < RenderLimits.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 = StatedViewport(); + 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, + }); + } + + /// + /// 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 + /// 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) => + 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.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.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs new file mode 100644 index 00000000..38bb38a6 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeSky.cs @@ -0,0 +1,265 @@ +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; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_SKY") != "0"; + + /// + /// 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 && + SameFixedState(pass.Pipeline.Description, description) && 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; + } + + /// + /// 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) + { + 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 = StatedViewport(); + 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(); + + SetPassContext(outer, outerFlags); + } +} 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/Platform/VulkanClientPlatform.NativeStated.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs new file mode 100644 index 00000000..51dd0e23 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeStated.cs @@ -0,0 +1,153 @@ +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 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: 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(); + + private readonly HashSet statedRefusalReported = new(); + + /// The program the client last used (glUseProgram); 0: none. + internal int statedProgram; + + /// 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 could not record. Tests 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); + } + + /// 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); + } + + /// 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); + } + + /// 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); + } + + /// 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 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 (reported once per program). + /// + private bool TryDrawStated(VAO? vao, int instances, int[]? starts, int[]? sizes, int groupCount) + { + if (vao != null && (vao.VaoId == 0 || vao.Disposed)) return false; + return RecordStatedDraw(vao?.VaoId ?? 0, instances, starts, sizes, groupCount); + } + + /// 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; + 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) + { + StatedDrawsForTests++; + return true; + } + RuntimeStats.drawCallsCount--; + return refusal == null ? false : Refuse(programId, refusal); + } + + private bool Refuse(int programId, string reason) + { + StatedRefusalsForTests++; + if (statedRefusalReported.Add(programId)) + { + 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; + } + +} diff --git a/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs new file mode 100644 index 00000000..8ba1212f --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.cs @@ -0,0 +1,715 @@ +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, 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. +// +// 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; } = Environment.GetEnvironmentVariable("OPTIMUM_VK_NATIVE_WORLD") != "0"; + + /// 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 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 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()); + + /// 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()); + + /// 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) + { + if (IsTransparentTarget(target) && nativeTransparentSlots != 0) return nativeTransparentSlots; + 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, + Func? blendFor = null, bool? depthWrite = null, + CompareOp? depthCompare = null, CullModeFlags? cull = null, bool samplesBoundDepth = false) + { + 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 = blendFor != null ? blendFor(formats.ColorFormats.Length) : NativeWorldBlend(formats, blending), + DepthTest = depth, + DepthWrite = depthWrite ?? depth, + DepthCompare = depthCompare ?? CompareOp.Less, + Cull = cull ?? CullModeFlags.None, + Topology = PrimitiveTopology.TriangleList, + SamplesBoundDepth = samplesBoundDepth, + }); + 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 = StatedViewport(); + 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 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(); + 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); + } + + /// + /// 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. + /// + /// 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) + { + if (quantity <= 0) + { + base.RenderParticles(model, quantity, particleTextureId); + 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. + 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 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 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); + // 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; + } + 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. 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 || + !ReferenceEquals(program, ShaderPrograms.Standard)) + { + return false; + } + + FrameBufferRef bound = CurrentFrameBuffer; + 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, + 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; + } + + /// + /// 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, PassDeclaration.DefaultFramebuffer, 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 = StatedViewport(); + 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 . + 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; + + /// + /// 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. + /// + /// 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 BeginDecalPass(int decalTextureId, int blockTextureId) + { + 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 generic stated multi-draw. + /// + internal bool TryDrawDecalPoolNative(MeshRef decalMesh, int[] indicesStarts, int[] indicesSizes, int groupCount) + { + if (!decalScopeActive) return false; + if (groupCount <= 0 || indicesStarts == null || indicesSizes == null) return false; + + if (!NativeWorldPrepare(nativeDecals, decalMesh, blending: true, depth: true, + out FrameBufferRef target, out VAO vao, out uint slots, out NativePipeline pipeline)) + { + return false; + } + + string outer = passContext; + PassFlags outerFlags = passContextFlags; + if (NativeWorldBeginPass("Decals", target, slots, + new[] { decalScopeDecalTextureId, decalScopeBlockTextureId })) + { + device.DrawNativeMeshMulti(pipeline, vao.VaoId, indicesStarts, indicesSizes, groupCount, new[] + { + 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.Shaders.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs new file mode 100644 index 00000000..a394670d --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Shaders.cs @@ -0,0 +1,250 @@ +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); + } + + /// + /// 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) + { + statedProgram = 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); + } + ForgetNativeChunkProgram(program.ProgramId); + device.DeleteProgram(program.ProgramId); + } + + public override void BindSampler(int unit, int samplerId) + { + stated.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) + { + // 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 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); + if (program.customSamplers.TryGetValue(samplerName, out var optimumSampler)) + { + stated.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); + } + 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) + { + NoteNativeProgramTexture(program.ProgramId, samplerName, textureId); + device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber); + stated.BindTexture(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/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs new file mode 100644 index 00000000..9d939407 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Stages.cs @@ -0,0 +1,71 @@ +using Optimum.Render.Vulkan.Graph; +using Vintagestory.API.Client; + +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. +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; } + + /// + /// 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); + } + + 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.State.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs new file mode 100644 index 00000000..26b3af66 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs @@ -0,0 +1,330 @@ +using Optimum.Render.Vulkan.Core; +using Silk.NET.Vulkan; +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) + { + stated.Wireframe = 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))); + } + + public override void GlScissor(int x, int y, int width, int height) + { + // 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; + } + + // 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; + private bool statedCull; + private bool statedCullBack = true; + private float statedLineWidth = 1f; + + public override void GlScissorFlag(bool enable) + { + scissorEnabled = enable; + stated.ScissorEnabled = enable; + } + + public override void GlEnableDepthTest() + { + statedDepthTest = true; + stated.DepthTest = true; + } + + public override void GlDisableDepthTest() + { + statedDepthTest = false; + stated.DepthTest = 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); + // 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) + { + stated.BindTexture(0, texture); + } + + public override void UnBindTextureCubeMap() + { + // Mirrors BindTextureCubeMap above, which binds to unit 0. + stated.BindTexture(0, 0); + } + + public override void GlToggleBlend(bool on, EnumBlendMode blendMode = EnumBlendMode.Standard) + { + 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); + } + } + // 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() + { + statedCull = false; + stated.CullEnabled = false; + } + + public override void GlEnableCullFace() + { + statedCull = true; + stated.CullEnabled = true; + } + + public override void GLLineWidth(float width) + { + statedLineWidth = width; + stated.LineWidth = 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) + { + statedDepthWrite = flag; + stated.DepthWrite = 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. + statedDepthFunc = (int)depthFunc; + stated.DepthCompare = GlEnums.CompareOpFrom((int)depthFunc); + } + + public override void GlCullFaceBack() + { + statedCullBack = true; + stated.CullBack = true; + } + + public override void GlCullFaceFront() + { + statedCullBack = false; + stated.CullBack = false; + } + + public override void GlEnableStencilTest() + { + stated.StencilTest = true; + } + + public override void GlDisableStencilTest() + { + stated.StencilTest = false; + } + + public override void GlStencilMask(int mask) + { + } + + public override void GlStencilFunc(int func, int refVal, int mask) + { + } + + public override void GlStencilOp(int sfail, int dpfail, int 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); + stated.SetColorMask(r, g, b, a); + } + + public override void GlClearStencil() + { + } + + 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.Taa.cs b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Taa.cs new file mode 100644 index 00000000..12405164 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Taa.cs @@ -0,0 +1,52 @@ +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Platform; + +// Vulkan-native plan, Phase 1A step 4: the device halves of the TAA motion windows and +// the FSR target selection, moved out of ClientPlatformWindows. The guards and the +// window state stay in the base (BeginMotionWrite, EndMotionWrite, BeginMotionOnlyWrite, +// BlitPrimaryToDefault); these are the calls the base makes once a window opens. +public partial class VulkanClientPlatform +{ + /// Primary's default colour set plus the motion attachment. + public override void EnableMotionDrawBuffers() + { + StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1); + } + + /// + /// Back to Primary's default colour set. 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. + /// + public override void RestorePrimaryDrawBuffers() + { + StateDrawBuffers(FrameBuffers[0].FboId, (1 << MotionAttachmentIndex) - 1); + } + + /// The motion attachment alone; the device takes the mask directly. + public override void EnableMotionOnlyDrawBuffers() + { + StateDrawBuffers(FrameBuffers[0].FboId, 1 << MotionAttachmentIndex); + } + + /// Replace-blending on the motion attachment (TAA P3). + public override void ApplyOptimumMotionBlendState() + { + if (!OptimumMotionWriteActive || MotionAttachmentIndex < 0) return; + 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; + 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) + { + StateDrawBuffers(target.FboId, 1); + } +} 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/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 new file mode 100644 index 00000000..4c4ceba3 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs @@ -0,0 +1,385 @@ +using System; +using System.Reflection; +using Vintagestory; +using Vintagestory.API.Config; +using Vintagestory.Client.NoObf; + +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, 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 +/// declares the virtuals this class relies on, and fails the install (OpenGL +/// fallback) instead of letting a call bypass an override mid-frame. +/// +public partial class VulkanClientPlatform : ClientPlatformWindows +{ + /// + /// The device this platform brought up in . Every + /// graphics override in the partial files calls it directly; null before a successful + /// install and after . + /// + private VulkanDevice device; + + /// Test seam: the device the overrides draw with. + internal VulkanDevice? GraphicsDevice => device; + + public const string ForceInstallFailureVariable = "OPTIMUM_VULKAN_FORCE_INSTALL_FAILURE"; + public const string ForcedInstallFailureReason = "forced by " + ForceInstallFailureVariable; + + /// A virtual the loaded lib must declare, matched by name and parameter type names. + internal readonly record struct ExpectedVirtual(bool OnAbstract, string Name, string[] ParameterTypeNames); + + /// + /// The injected virtuals on and the members + /// the patcher virtualizes in place on + /// (Optimum.Patcher/Program.cs, methodsToVirtualize). + /// + internal static readonly ExpectedVirtual[] ExpectedVirtuals = + { + new(true, "InitializeGraphics", new[] { "IntPtr", "Int32", "Int32", "String&" }), + new(true, "ShutdownGraphics", Array.Empty()), + new(false, "SetupDefaultFrameBuffers", Array.Empty()), + new(false, "DisposeFrameBuffers", new[] { "List`1" }), + 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()), + new(true, "EnableMotionOnlyDrawBuffers", Array.Empty()), + 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" }), + // 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" }), + // 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" }), + 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()), + // 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" }), + // "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" }), + // 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. + 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()), + // Phase 3b stage 2: the entity draw seam - every sub-mesh of a multi-texture mesh. + new(true, "RenderEntityMesh", new[] { "MeshRef", "String", "Int32" }), + // 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, "RenderGuiQuad", new[] { "MeshRef", "Int32" }), + new(true, "RenderParticles", new[] { "MeshRef", "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" }), + 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. + new(true, "OptimumTaaResolveDraw", + new[] { "FrameBufferRef", "FrameBufferRef", "Single[]", "Single[]", "Boolean" }), + new(true, "OptimumTaaSharpenDraw", new[] { "FrameBufferRef", "Int32" }), + }; + + /// + /// 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). 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"), + ShaderProgramOverriddenByMods = Vintagestory.API.Config.OptimumConfig.IsShaderProgramOverriddenByMods, + }; + + /// Test seam: where the crash marker goes; null means . + internal string? CrashMarkerDataPath; + + public VulkanClientPlatform(Logger logger) : base(logger) + { + } + + /// + /// Checks the loaded lib against . Reflection + /// only; never throws. + /// + internal static bool VerifyHost(Type abstractType, Type windowsType, out string? reason) + { + try + { + if (windowsType.IsSealed) + { + reason = windowsType.FullName + " is sealed in the loaded VintagestoryLib (not patched for this renderer)"; + return false; + } + if (!windowsType.IsSubclassOf(abstractType)) + { + reason = windowsType.FullName + " does not derive from " + abstractType.FullName; + return false; + } + + foreach (ExpectedVirtual expected in ExpectedVirtuals) + { + Type owner = expected.OnAbstract ? abstractType : windowsType; + MethodInfo? method = FindDeclared(owner, expected); + if (method == null || !method.IsVirtual || method.IsFinal) + { + reason = "the loaded VintagestoryLib lacks the virtual " + owner.Name + "." + expected.Name + + " (not patched for this renderer)"; + return false; + } + } + + 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; + } + catch (Exception error) + { + reason = "the platform self-check threw: " + error.Message; + return false; + } + } + + private static MethodInfo? FindDeclared(Type owner, ExpectedVirtual expected) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (MethodInfo method in owner.GetMethods(flags)) + { + if (method.Name != expected.Name) continue; + ParameterInfo[] parameters = method.GetParameters(); + if (parameters.Length != expected.ParameterTypeNames.Length) continue; + bool matches = true; + for (int i = 0; i < parameters.Length && matches; i++) + matches = parameters[i].ParameterType.Name == expected.ParameterTypeNames[i]; + if (matches) return method; + } + return null; + } + + internal static bool IsInstallFailureForced() => + Environment.GetEnvironmentVariable(ForceInstallFailureVariable) == "1"; + + /// + /// 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) + { + string? hostReason; + if (!VerifyHost(typeof(ClientPlatformAbstract), typeof(ClientPlatformWindows), out hostReason)) + { + reason = hostReason!; + return false; + } + + if (IsInstallFailureForced()) + { + reason = ForcedInstallFailureReason; + return false; + } + + reason = 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) + { + return true; + } + + VulkanDevice? device = null; + try + { + device = DeviceFactory(); + + // 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. + OptimumRenderBootstrap.WriteCrashMarker(CrashMarkerDataPath ?? GamePaths.DataPath); + + if (!device.Initialize(windowHandle, width, height, out string failureReason)) + { + device.Dispose(); + OptimumRenderBootstrap.ClearCrashMarker(); + reason = failureReason; + return false; + } + + 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. + InstallModPassHooks(); + OptimumRender.ActiveBackend = EnumRenderBackend.Vulkan; + OptimumForkGraphics.Active = new VulkanForkGraphics(this, device); + return true; + } + catch (Exception error) + { + try + { + device?.Dispose(); + } + catch (Exception) + { + // The install already failed; the reason below is the useful one. + } + OptimumRenderBootstrap.ClearCrashMarker(); + reason = error.Message; + return false; + } + } + + /// + /// Shuts the device down, returns the backend state to OpenGL and clears the + /// crash marker. Safe to call more than once and after a failed install. + /// + public override void ShutdownGraphics() + { + // The bridge goes first: nothing may reach a device that is being torn down. + OptimumForkGraphics.Active = null; + RemoveModPassHooks(); + try + { + ReleaseAmbientOcclusion(); + } + catch (Exception) + { + // Released with the device below either way. + } + try + { + device?.Dispose(); + } + catch (Exception) + { + // A driver throwing on teardown must not stop the client exiting. + } + + device = null; + RenderStageListener = 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..763bc822 --- /dev/null +++ b/Optimum.Render.Vulkan/Platform/VulkanForkGraphics.cs @@ -0,0 +1,98 @@ +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. +/// +/// 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 +{ + private readonly VulkanDevice device; + private readonly VulkanClientPlatform platform; + + public VulkanForkGraphics(VulkanClientPlatform platform, VulkanDevice device) + { + this.platform = platform; + 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) + { + platform.NoteForkTexture(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) + { + platform.NoteForkDrawBuffers(framebufferId, attachmentMask); + } + + public override void BindFramebuffer(int framebufferId) + { + platform.NoteForkFramebuffer(framebufferId); + } + + public override void BindDefaultFramebuffer() + { + platform.NoteForkFramebuffer(0); + } + + 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); + } + + public override void SetDepthTest(bool enabled) + { + platform.NoteForkDepthTest(enabled); + } + + public override void SetBlendEnabled(bool enabled) + { + platform.NoteForkBlend(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/Present/IPresentPath.cs b/Optimum.Render.Vulkan/Present/IPresentPath.cs new file mode 100644 index 00000000..687d176e --- /dev/null +++ b/Optimum.Render.Vulkan/Present/IPresentPath.cs @@ -0,0 +1,124 @@ +using System; +using Optimum.Render.Vulkan.Graph; +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; + /// 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) + { + _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. 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) + { + + 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); + } + + // 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/Present/Swapchain.cs b/Optimum.Render.Vulkan/Present/Swapchain.cs new file mode 100644 index 00000000..20caba5a --- /dev/null +++ b/Optimum.Render.Vulkan/Present/Swapchain.cs @@ -0,0 +1,744 @@ +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; + +/// +/// 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 +{ + 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; + + /// + /// 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) + { + _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, ILatencyBackend? latency = null, + SwapchainCreateChain? createChain = null) + { + 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); + // 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; + 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, + }; + + // 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. + if (old != null) + { + _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) + { + 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++; + // 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; + } + + 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); + } + + /// + /// 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, + 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"); + } + + return presentId; + } + + 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; + // Nothing may be called against these handles again; the backend outlives + // the swapchain (the device disposes it last). + Latency.OnSwapchainRetired(); + + 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..ce0dffc1 --- /dev/null +++ b/Optimum.Render.Vulkan/Present/SwapchainRetirement.cs @@ -0,0 +1,289 @@ +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; + } + + /// + /// 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; + + /// + /// 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/Shaders/FrameGlobals.cs b/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs new file mode 100644 index 00000000..ea85980b --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/FrameGlobals.cs @@ -0,0 +1,296 @@ +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; + + // ------------------------------------------------------------ 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/GlslParser.cs b/Optimum.Render.Vulkan/Shaders/GlslParser.cs new file mode 100644 index 00000000..f7f1800f --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/GlslParser.cs @@ -0,0 +1,712 @@ +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 = ""; + + /// Absolute start of the storage keyword (uniform, buffer, in, ...), or -1. + public int StorageKeywordStart = -1; + + 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; + declaration.StorageKeywordStart = start + cursor - word.Length; + 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..84b167f9 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/GlslReservedWords.cs @@ -0,0 +1,139 @@ +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++; + + 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 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/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/NativeShaderLibrary.cs b/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs new file mode 100644 index 00000000..4c6a7057 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/NativeShaderLibrary.cs @@ -0,0 +1,526 @@ +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); 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"; + + 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 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) + { + 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; + } + + reason = ""; + if (manifest.Toolchain != toolchain) + { + (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)"; + } + + 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. + /// + 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/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/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 new file mode 100644 index 00000000..8d28fd34 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ProgramInterfaceLayout.cs @@ -0,0 +1,743 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Optimum.Render.Vulkan.Core; +using Vintagestory.API.Client; + +namespace Optimum.Render.Vulkan.Shaders; + +/// 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 = ""; + 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; +} + +/// +/// 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 = ""; + + /// + /// 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 = SetConvention.StorageSet; + public int Binding; +} + +/// +/// 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 partial class ProgramInterfaceLayout +{ + public const string BlockTypeName = "OptimumUniforms"; + 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 ). + /// 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(); + 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(); + + /// 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(); + + /// 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); + + /// + /// 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 program record 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; + } + + 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 + // 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. + /// + /// + /// 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, + IReadOnlySet? includes = null) + { + var layout = new ProgramInterfaceLayout(); + int offset = 0; + int nextNamedBinding = SetConvention.NamedBlockFirstBinding; + + foreach ((EnumShaderType stage, ParsedShader parsed) in stages) + { + foreach (GlslDeclaration declaration in parsed.Declarations) + { + switch (declaration.Kind) + { + case GlslDeclarationKind.DefaultUniform: + AddDefaultUniform(layout, declaration, stage, includes, ref offset); + break; + case GlslDeclarationKind.OpaqueUniform: + AddSampler(layout, declaration, stage); + break; + case GlslDeclarationKind.UniformBlock: + AddBlock(layout, layout.UniformBlocks, declaration, ref nextNamedBinding); + break; + case GlslDeclarationKind.StorageBlock: + AddBlock(layout, layout.StorageBlocks, declaration, ref nextNamedBinding); + break; + } + } + } + + AssignInterfaceLocations(layout, stages, declaredAttributes); + + layout.BlockSize = offset; + return layout; + } + + // ------------------------------------------------------------------ uniforms + + private static void AddDefaultUniform( + ProgramInterfaceLayout layout, GlslDeclaration declaration, EnumShaderType stage, + IReadOnlySet? includes, 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; + } + + // 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); + 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; + } + + /// + /// 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.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, + 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( + ProgramInterfaceLayout layout, List blocks, GlslDeclaration declaration, ref int nextNamedBinding) + { + foreach (BlockBinding existing in blocks) + { + if (existing.BlockName == declaration.Name) return; + } + + 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; + } + + blocks.Add(new BlockBinding { BlockName = declaration.Name, Binding = binding }); + } + + 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 + + /// + /// 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(); + // 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) + { + 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)); + fragmentOutputDeclarations.Add(declaration); + fragmentSource = parsed.Source; + } + 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); + fragmentOutputDeclarations.Add(declaration); + fragmentSource = parsed.Source; + } + 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); + } + } + } + + 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 (!TryGetWrittenFragmentOutputElements(fragmentSource, declaration.Name, out HashSet? writtenElements)) + { + continue; + } + int span = LocationSpan(declaration); + // An index on a non-array output selects a component (or a matrix + // column), not an attachment, so it still writes the whole span. + if (writtenElements == null || declaration.ArrayLength == 0) + { + for (int i = 0; i < span; i++) layout.WrittenFragmentOutputs.Add(location + i); + continue; + } + + // Only some elements of an output array are stored to. Marking the + // whole span written would leave colour writes on for attachments + // the shader never touches, and Vulkan then writes undefined data + // into them (GL would have preserved the attachment). + int perElement = span / Math.Max(declaration.ArrayLength == 0 ? 1 : declaration.ArrayLength, 1); + perElement = Math.Max(perElement, 1); + foreach (int element in writtenElements) + { + for (int i = 0; i < perElement; i++) + { + int slot = location + element * perElement + i; + if (slot < location + span) layout.WrittenFragmentOutputs.Add(slot); + } + } + } + } + + /// + /// 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) + => TryGetWrittenFragmentOutputElements(source, name, out _); + + /// + /// Which elements of a fragment output the body stores to. + /// Returns false when nothing stores to it at all. On true, + /// is null when the whole variable is written - + /// a plain or swizzled store, or an index the parser cannot fold to a + /// constant - and otherwise holds the constant element indices that are. + /// + internal static bool TryGetWrittenFragmentOutputElements( + string source, string name, out HashSet? elements) + { + elements = null; + var store = new System.Text.RegularExpressions.Regex( + @"(?(); + foreach (System.Text.RegularExpressions.Match match in store.Matches(source)) + { + int lineStart = source.LastIndexOf('\n', Math.Max(match.Index - 1, 0)) + 1; + string before = source.Substring(lineStart, match.Index - lineStart); + if (System.Text.RegularExpressions.Regex.IsMatch(before, @"\bout\b|\bin\b|\buniform\b")) continue; + + assigned = true; + string suffix = match.Groups[1].Value; + if (!suffix.StartsWith("[", StringComparison.Ordinal)) + { + // Whole variable or a swizzle of it: everything is written. + elements = null; + return true; + } + + int close = suffix.IndexOf(']'); + string index = close < 0 ? "" : suffix.Substring(1, close - 1).Trim(); + if (!int.TryParse(index, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out int element) + || element < 0) + { + // Dynamic index: assume every element can be written. + elements = null; + return true; + } + indices.Add(element); + } + + if (!assigned) return false; + elements = indices; + return true; + } + + + /// + /// 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 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 new file mode 100644 index 00000000..ec7ed4fd --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/SetConvention.cs @@ -0,0 +1,113 @@ +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 | 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 +/// 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 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; + + /// + /// 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", "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/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.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 new file mode 100644 index 00000000..ec4c2ec3 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderCompiler.cs @@ -0,0 +1,383 @@ +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 partial 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); + } + } + + /// + /// 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; + } + + /// + /// 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); + + /// + /// 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, 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); + 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); + if (versionIndex < 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(lineEnd + 1, 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, + ComputeStageTag => ShaderKind.ComputeShader, + _ => 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..08d56ca5 --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderRewriter.cs @@ -0,0 +1,656 @@ +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 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. +/// +/// 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. +/// +/// 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, emitDepthRemap, edits); + + foreach (GlslDeclaration declaration in parsed.Declarations) + { + switch (declaration.Kind) + { + case GlslDeclarationKind.DefaultUniform: + // 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, "")); + } + break; + + case GlslDeclarationKind.OpaqueUniform: + if (layout.SamplersByName.TryGetValue(declaration.Name, out SamplerBinding? sampler)) + { + if (sampler.IsFrameTexture) + { + 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, asStorage: true); + break; + + case GlslDeclarationKind.StorageBlock: + AddBlockEdit(layout.StorageBlocks, declaration, edits, asStorage: false); + break; + + case GlslDeclarationKind.Input: + case GlslDeclarationKind.Output: + AddLocationEdit(layout, declaration, stage, edits); + break; + } + } + + AddSamplerReferenceEdits(parsed, layout, stage, edits); + + if (emitDepthRemap) + { + AddDepthRemapEdits(parsed, stage, edits, result); + } + + result.Code = ApplyEdits(source, edits); + return result; + } + + // -------------------------------------------------------------------- header + + private static void AddHeaderEdits( + ParsedShader parsed, ProgramInterfaceLayout layout, EnumShaderType stage, bool emitDepthRemap, + List edits) + { + 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 || 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. + 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'))); + } + 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 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 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 + /// 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 = {SetConvention.StorageSet}"); + builder.Append(CultureInfo.InvariantCulture, $", binding = {SetConvention.ProgramRecordBinding}) 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(); + } + + /// + /// 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 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; + + if (asStorage) + { + 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) + { + 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", 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 (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, + params string[] removals) + { + var parts = new List(); + var overridden = new HashSet(removals, 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(value == null ? key : $"{key} = {value}"); + } + + // 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 + + /// + /// 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 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"); + 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" + + " " + DepthRemapStatement + "\n" + + "}\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. 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) + { + 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, call.Length, EmitVertexReplacementName)); + found++; + } + + if (found == 0) + { + result.Errors.Add("geometry stage never calls EmitVertex(), so no vertex gets the Vulkan depth range"); + } + } + + // --------------------------------------------------------------------- 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..b09c4b7e --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/ShaderTranslator.cs @@ -0,0 +1,162 @@ +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; + + /// + /// 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; +} + +/// +/// 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, + }; + + /// + /// 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, + IReadOnlySet? includes = 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; + + if (parsed.Count == 0) + { + program.Errors.Add("no shader stage survived translation"); + return program; + } + + program.Layout = ProgramInterfaceLayout.Build(parsed, declaredAttributes, includes); + 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/Shaders/SpecializationConvention.cs b/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs new file mode 100644 index 00000000..b171346a --- /dev/null +++ b/Optimum.Render.Vulkan/Shaders/SpecializationConvention.cs @@ -0,0 +1,49 @@ +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"), + // 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/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.Render.Vulkan/Transfer/ReadbackManager.cs b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs new file mode 100644 index 00000000..edea8c91 --- /dev/null +++ b/Optimum.Render.Vulkan/Transfer/ReadbackManager.cs @@ -0,0 +1,154 @@ +using System; +using Silk.NET.Vulkan; + +// The Transfer/ folder follows the plan's layout; the namespace stays Core until +// the renderer is reorganised. +namespace Optimum.Render.Vulkan.Core; + +/// +/// A pending copy of GPU data into a slot's readback arena: valid to read once +/// the Frame timeline passed , and until the slot starts +/// its next frame. +/// +internal readonly record struct ReadbackTicket(VulkanBuffer Buffer, ulong Offset, ulong Size, ulong FrameValue); + +/// +/// Readback inside a frame without ending it. +/// +/// records a barrier and a copy into the current +/// slot's readback arena (a host-visible buffer, bump-allocated, reset when the +/// slot starts a frame). The caller then submits what the frame has recorded +/// with , which continues recording in the +/// same slot with every arena cursor kept, so the frame counter does not move and +/// uniform snapshots stay valid. A caller that needs the bytes now - a +/// screenshot, the parity dump - waits on that one timeline value; nothing else +/// waits. +/// +internal sealed unsafe class ReadbackManager : IDisposable +{ + public const ulong MinimumArenaSize = 1UL << 20; + + // Depth copies need a buffer offset that is a multiple of 4; 8 covers every + // format the dump path reads. + private const ulong OffsetAlignment = 8; + + private readonly VulkanContext _context; + private readonly TextureManager _textures; + private readonly FrameRing _frames; + private readonly VulkanBuffer?[] _arenas; + private readonly ulong[] _cursors; + private bool _disposed; + + public ReadbackManager(VulkanContext context, TextureManager textures, FrameRing frames) + { + _context = context; + _textures = textures; + _frames = frames; + _arenas = new VulkanBuffer?[frames.FramesInFlight]; + _cursors = new ulong[frames.FramesInFlight]; + } + + /// The slot's previous frame has finished and every ticket into its arena was read. + public void BeginSlot(int slotIndex) => _cursors[slotIndex] = 0; + + /// Bytes the slot's arena can hold. Tests only. + internal ulong ArenaCapacity(int slotIndex) => _arenas[slotIndex]?.Size ?? 0; + + /// + /// Records a copy of level 0 of (the given region + /// and aspect) into the current slot's arena, and the transitions around it. + /// 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, uint mipLevel = 0) + { + FrameSlot slot = _frames.Current; + CommandBuffer commandBuffer = slot.CommandBuffer; + // 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); + + var region = new BufferImageCopy + { + BufferOffset = offset, + ImageSubresource = new ImageSubresourceLayers(aspect, mipLevel, 0, 1), + ImageOffset = new Offset3D(x, y, 0), + ImageExtent = new Extent3D(width, height, 1), + }; + _context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, arena.Handle, 1, ®ion); + + if (restore != ImageLayout.Undefined) _textures.TransitionTexture(commandBuffer, texture, restore); + + 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. + public bool IsReady(ReadbackTicket ticket) => _frames.Timeline.FrameCompleted >= ticket.FrameValue; + + /// + /// Waits for the ticket's timeline value (counted at the readback site) and + /// copies the bytes out. The ticket's command buffer must have been submitted. + /// + public void WaitAndCopy(ReadbackTicket ticket, IntPtr destination) + { + _frames.Timeline.WaitForFrame(ticket.FrameValue, WaitSite.Readback); + System.Buffer.MemoryCopy((void*)((nint)ticket.Buffer.Mapped + (nint)ticket.Offset), + (void*)destination, (long)ticket.Size, (long)ticket.Size); + } + + private VulkanBuffer Reserve(int slotIndex, ulong bytes, ulong alignment, out ulong offset) + { + VulkanBuffer? arena = _arenas[slotIndex]; + ulong aligned = (_cursors[slotIndex] + alignment - 1) / alignment * alignment; + + if (arena == null || aligned + bytes > arena.Size) + { + ulong size = arena == null ? MinimumArenaSize : arena.Size * 2; + while (size < bytes) size *= 2; + + // A submitted copy may still be writing the old arena and a ticket may + // 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, MemoryPoolClass.Staging); + _arenas[slotIndex] = arena; + aligned = 0; + } + + offset = aligned; + _cursors[slotIndex] = aligned + bytes; + return arena; + } + + /// The caller has waited for every signalled frame first. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + for (int i = 0; i < _arenas.Length; i++) + { + _arenas[i]?.Dispose(); + _arenas[i] = null; + } + } +} diff --git a/Optimum.Render.Vulkan/Transfer/UploadManager.cs b/Optimum.Render.Vulkan/Transfer/UploadManager.cs new file mode 100644 index 00000000..1b9169a1 --- /dev/null +++ b/Optimum.Render.Vulkan/Transfer/UploadManager.cs @@ -0,0 +1,507 @@ +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, MemoryPoolClass.Staging); + _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). 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, + 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, + readerStage, readerAccess); + 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, MemoryPoolClass.Staging); + + 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.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.Native.cs b/Optimum.Render.Vulkan/VulkanDevice.Native.cs new file mode 100644 index 00000000..efe51801 --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.Native.cs @@ -0,0 +1,1024 @@ +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: 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; + + /// + /// 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 = RenderLimits.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!; +} + +/// +/// 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); + } + } + 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; + + 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; + + /// + /// 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; + + /// + /// 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; + + /// + /// 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; +} + +/// +/// 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. 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 +{ + 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 _nativePasses; + private long _nativeDraws; + private long _genericPasses; + private long _genericDraws; + 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, + int VertexLayoutId, PolygonMode PolygonMode, FrontFace FrontFace, float LineWidth, + bool SamplesBoundDepth); + + /// 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; + internal long NativeInstancedDrawsForTests => _nativeInstancedDraws; + internal long NativeIndirectDrawsForTests => _nativeIndirectDraws; + + /// Distinct native pipelines this device holds. Tests only. + internal int NativePipelinesForTests => _nativePipelines.Count; + + /// 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; + + return _targets.DeclaredFormats(target, colorSlots); + } + + /// + /// 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. + /// + /// + /// The unit a program's sampler reads: the client's SetSamplerUnit mapping, else the sampler's + /// declaration order - the resolution the removed emulated draw made. -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 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; + + + /// 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 : ""; + + /// + /// 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); + + /// + /// 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 + ? (_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 + /// 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; + } + 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; + int count = description.Targets.ColorFormats.Length; + + // 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++) + { + 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); + + // 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); + if (_nativePipelines.TryGetValue(cacheKey, out NativePipeline? cached) && + ReferenceEquals(cached.Program, program)) + { + return cached; + } + + // 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, 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, + TargetFormatsId: formatsId, + BlendId: -(bakedBlendId + 1), + PolygonMode: description.PolygonMode, + TopologyClass: GlEnums.TopologyClassOf(description.Topology)); + + var request = new GraphicsPipelineCache.PipelineRequest + { + Program = program, + VertexLayout = vertexLayout, + Targets = description.Targets, + Blend = baked, + PolygonMode = description.PolygonMode, + 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. + _pipelines.Prepare(key, request); + 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); + + for (int slot = 0; slot < RenderLimits.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; + if (pass.Generic) _genericPasses++; + else _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() => 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 what a render system does between + /// its draws (its uniforms by name) happens outside a native pass. + /// + internal void EndNativePass(bool keepScope) + { + if (_nativePass == null) return; + + _nativePass = null; + _nativeTarget = null; + if (!_frameActive || keepScope) 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))); + } + + /// + /// 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"); + return false; + } + + NativePassDescription pass = _nativePass; + VulkanFramebuffer bound = _nativeTarget; + if (!ReferenceEquals(_targets.Bound, bound)) + { + 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 = Commands; + ReleaseReadSelfCopies(); + EnsureBindlessPlaceholdersReadable(commandBuffer); + + // The pass's reads, put into the layout a shader read needs. A colour attachment of + // its own target is sampled through a pooled ReadSelf copy taken before the scope + // opens - the atlas compositions (BlendedTextureManager, RenderTextureIntoFrameBuffer) + // copy one region of an atlas into another region of the same atlas, and the copy is + // what the draw samples (SnapshotColorAttachment). The bound + // depth attachment with depth writes off is sampled in place, which the pipeline + // declares (NativePipelineDescription.SamplesBoundDepth) and the scope then holds + // read-only. + bool depthReadOnly = false; + for (int i = 0; i < textures.Length; i++) + { + VulkanTexture? texture = _textures.Get(textures[i].TextureId); + if (texture == null) continue; + if (_targets.IsBoundDepth(textures[i].TextureId)) + { + if (!pipeline.Description.SamplesBoundDepth) + { + AddDiagnostic("native pass '" + pass.Name + "' samples texture " + textures[i].TextureId + + ", the depth attachment of its own target, through a pipeline that does not declare it"); + return false; + } + depthReadOnly = true; + continue; + } + if (_targets.IsAttachmentOfBound(textures[i].TextureId)) + { + if (texture.Aspect != ImageAspectFlags.ColorBit) + { + AddDiagnostic("native pass '" + pass.Name + "' samples texture " + textures[i].TextureId + + ", a non-colour attachment of its own target"); + return false; + } + SnapshotColorAttachment(commandBuffer, textures[i].TextureId, texture); + continue; + } + _targets.FlushPendingClears(commandBuffer, texture); + if (texture.Layout == ImageLayout.ShaderReadOnlyOptimal) + { + _uploads.NoteUse(commandBuffer, texture); + continue; + } + _targets.EndRendering(commandBuffer); + _textures.Require(_barriers, commandBuffer, texture, ResourceUsage.SampleFragment); + } + PrepareUnnamedFrameTextures(commandBuffer, program, textures); + _barriers.Flush(commandBuffer); + + // Decided before the scope opens, since it decides the depth attachment's layout. + _targets.SetDepthReadOnly(depthReadOnly); + _targets.EnsureRendering(commandBuffer); + + RenderTargetFormats scope = _targets.ScopeFormats(bound); + 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; + + // A ReadSelf copy taken above stands in for the attachment it copies. + VulkanTexture? texture = _textures.Get( + _sampledTextureOverrides.TryGetValue(sampled.TextureId, out int readSelfCopy) + ? readSelfCopy + : 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; + } + + // The bound depth attachment is sampled in the read-only depth layout, the one the + // scope holds it in, exactly as the emulated resolve keys it. + ImageLayout layout = depthReadOnly && _targets.IsBoundDepth(sampled.TextureId) + ? ImageLayout.DepthReadOnlyOptimal + : ImageLayout.ShaderReadOnlyOptimal; + + 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, layout); + if (texture == null) VulkanStats.NoteSamplerPlaceholder(); + int frameIndex = FrameTextureIndex(sampler.FrameBinding); + lock (_frameTextureLock) + { + _frameTextureValues[frameIndex] = value; + _frameTextureIds[frameIndex] = texture == null ? 0 : sampled.TextureId; + } + continue; + } + + uint slot = _bindless!.Resolve(texture, sampler.Kind, sampling, layout); + VulkanStats.NoteBindlessSlotResolution(); + BitConverter.TryWriteBytes(_pushShadow.AsSpan(sampler.PushOffset, ProgramInterfaceLayout.SlotBytes), slot); + } + + BindProgramSets(commandBuffer, program, meshId); + EmitNativeDynamicState(commandBuffer, bound, pass, pipeline); + + target = bound; + return true; + } + + /// + /// 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)RenderLimits.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 = pass.Scissor ?? new Rect2D(new Offset2D(0, 0), new Extent2D(target.Width, target.Height)), + CullMode = description.Cull, + FrontFace = description.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, + // 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, + }; + + 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.NativeMesh.cs b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs new file mode 100644 index 00000000..ae121a2e --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs @@ -0,0 +1,216 @@ +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 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). +/// +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, 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) => + 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) + { + 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: + _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 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 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 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.Render.Vulkan/VulkanDevice.cs b/Optimum.Render.Vulkan/VulkanDevice.cs new file mode 100644 index 00000000..7920d7ff --- /dev/null +++ b/Optimum.Render.Vulkan/VulkanDevice.cs @@ -0,0 +1,3456 @@ +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 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, +/// 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 partial class VulkanDevice : IDisposable +{ + private VulkanContext _context = null!; + private UploadManager _uploads = null!; + 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!; + + /// 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. + internal int CachedDescriptorSets => _descriptors.Count; + 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; + /// 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; } + + /// + /// 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; } + + /// + /// 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"; + + /// 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; + + /// + /// 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 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; + + 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. + 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 readonly Dictionary _stagedStages = 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; + + /// + /// 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 BindlessTextureTable? _bindless; + private SharedPipelineLayout? _sharedLayout; + + /// + /// 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 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; + + private VulkanBuffer? _defaultAttributes; + + /// + /// 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 VulkanBuffer? _placeholderUniforms; + + // 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). + 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; + + /// 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 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"); + + /// + /// 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 = + ResolveValidationLogPath(ValidationSetting, DefaultValidationLogPath); + + /// + /// A setting that names a path is used as one; anything else (the bare "1") + /// only switches the layers on and mirrors to . + /// Windows separators count as a path too, so "C:\logs\vulkan.log" is not + /// silently redirected to the temp file. + /// + internal static string? ResolveValidationLogPath(string? setting, string fallback) + { + if (setting == null) return null; + return setting.Contains('/') || setting.Contains('\\') ? setting : fallback; + } + + /// + /// OPTIMUM_VULKAN_VALIDATION_FEATURES: comma list of "sync" (synchronization + /// 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") ?? ""; + + /// + /// 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) + { + if (ValidationLogPath == null) return; + try + { + System.IO.File.AppendAllText(ValidationLogPath, message + "\n"); + } + catch (Exception error) when ( + error is System.IO.IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + // A diagnostic write must never take the device down: a read-only + // directory or a malformed path is a lost log line, nothing more. + } + } + + 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, + ValidationFeatures = ValidationFeatureSetting, + DebugCallback = message => + { + AddDiagnostic(SanitiseForClientLog(message)); + MirrorValidationMessage(message); + if (RenderTrace.Enabled) + 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. + RequiredInstanceExtensions = headless + ? Array.Empty() + : WindowSurface.RequiredInstanceExtensions(), + }; + + ConfigureContextOptions?.Invoke(options); + + 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. + AddDiagnostic(VulkanContext.ErrorPrefix + message); + MirrorValidationMessage(message); + }; + VulkanResult.DescribeDeviceLoss = DescribeDeviceLoss; + MirrorValidationMessage("--- device up on " + _context.Capabilities.DeviceName + + "; 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") + + "; color write tier " + DeviceCaps.Token(_context.Capabilities.ColorWriteTier) + + (_context.Capabilities.DynamicColorBlend ? " (dynamic blend)" : "") + + "; bindless sampled images per stage " + + _context.Capabilities.DescriptorIndexing.MaxPerStageDescriptorUpdateAfterBindSampledImages + + " (needs " + DescriptorIndexingFloor.RequiredSampledImages + ")" + + "; 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; + VulkanStats.MemorySource = _context.Allocator; + // 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); + // 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); + _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); + // 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(); + // 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); + string? cacheRoot = ResolveShaderCacheRoot(ShaderCacheDirectory, + Environment.GetEnvironmentVariable("OPTIMUM_VULKAN_SHADER_CACHE")); + byte[]? pipelineSeed = null; + if (cacheRoot != null) + { + _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"), + Environment.GetEnvironmentVariable("OPTIMUM_PARITY_DUMP"), + Environment.GetEnvironmentVariable("OPTIMUM_HEADLESS_FRAMES")); + _pipelines.AsyncCompiles = !synchronousPipelines; + _pipelines.KeyLog = _pipelinePersistence?.KeyLog; + _descriptors = new DescriptorCache(_context); + _compute = new ComputePipelineCache(_context, () => _pipelines.DriverCache); + // 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); + 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); + _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); + _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 + { + BinaryCache = cacheRoot == null ? null : new ShaderBinaryCache(System.IO.Path.Combine(cacheRoot, "spirv")), + }; + LoadNativeShaders(); + MirrorValidationMessage(cacheRoot == null + ? "--- shader cache off" + : "--- shader cache " + cacheRoot + "; pipeline cache " + + (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, a frame capture or the device setting)" : "compile blocking (no pipelineCreationCacheControl)")); + 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)) + { + failureReason = surfaceError ?? "could not create a presentation surface"; + return false; + } + + if (!Swapchain.TryCreate(_context, surface, (uint)width, (uint)height, _vsync, _frames.Timeline, + 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); + } + + failureReason = null!; + return true; + } + + 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; + 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); + + 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 zero-filled buffer that fills any shader-declared uniform block + /// 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 + /// 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 | BufferUsageFlags.StorageBufferBit, + 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); + if (_defaultColor > 0) ReleaseTexture(_defaultColor); + if (_defaultDepth > 0) ReleaseTexture(_defaultDepth); + + _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; } + + /// + /// Test seam: adjusts the context options builds, + /// just before the context is created (validation features, a message + /// recorder, poison mode). Null in the client. + /// + internal Action? ConfigureContextOptions { 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() + { + // Phase 1B step 6: a volatile read; the message is built only when there is one. + if (_errorCount == 0) return null!; + + lock (_errors) + { + if (_errors.Count == 0) return null!; + string joined = string.Join("\n", _errors); + _errors.Clear(); + _errorCount = 0; + return joined; + } + } + + /// + /// 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)) + { + // 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) + { + 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() + { + // 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(); + if (_lastFrameStart != 0) + { + VulkanStats.NoteFrameInterval( + (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). + _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. + ReleaseReadSelfCopies(); + _readSelfCopies.EndFrame(); + VulkanStats.NoteTransientFrame(_transients.PhysicalBytes + _transients.OptedInBytes, + _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. + _bindless?.BeginFrame(); + _readSelfCopies.Collect(); + _frameActive = true; + _frameCounter++; + 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(); + _computeArenas[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); + _readbacks.BeginSlot(slot.Index); + + // Sets naming resources deleted since last frame leave the cache now and + // are freed once the Frame timeline has passed every frame that could + // have 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 + { + // One sample is several lines (see VulkanStats); the first keeps the original format. + System.IO.File.AppendAllText(StatsLogPath, sample + "\n"); + } + catch (System.IO.IOException) + { + } + } + } + + /// Stopwatch timestamp of the last BeginFrame, 0 before the first. + private long _lastFrameStart; + + /// Deferred destructions still waiting on the timelines. Tests only. + internal int PendingRetirementsForTests => _frames.PendingDeletionCount; + + /// The frame ring's timelines. Tests only. + internal FrameTimeline TimelineForTests => _frames.Timeline; + + /// 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(); + ForgetBoundDescriptors(); + } + + // ------------------------------------------------------------------ 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)); + + /// 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) + : 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 + { + set => _meshes.DeviceLocalStaticBuffers = value; + } + + /// The mesh store. Tests only. + internal MeshManager MeshesForTests => _meshes; + + /// 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"); + + /// + /// 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(), + }; + + /// + /// 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; + + 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); + + 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; + // 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. + if (_swapchain == null || _presentPath == null) return; + + bool acquired = _swapchain.TryAcquire(out PresentTarget target); + long acquireReturned = System.Diagnostics.Stopwatch.GetTimestamp(); + bool renderCompletedAtAcquire = _frames.Timeline.FrameCompleted >= renderValue; + ReportRebuildFailure(); + if (!acquired) + { + LastPresentTimingsForTests = new PresentTimings(presentEntry, frameSubmitted, acquireReturned, 0, + renderValue, 0, renderCompletedAtAcquire, false); + return; + } + + 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(); + + // 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); + + 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; + } + + /// 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); + + /// The last Present's timings. Tests only. + internal PresentTimings LastPresentTimingsForTests { get; private set; } + + /// The swapchain, null when headless. Tests only. + internal Swapchain? SwapchainForTests => _swapchain; + + /// The present path's acquire wait stage. Tests only. + internal PipelineStageFlags PresentAcquireWaitStageForTests => + _presentPath?.AcquireWaitStage ?? PresentWaitStages.BlitAcquireWait; + + private void ReportRebuildFailure() + { + string? failure = _swapchain?.RebuildFailure; + if (failure != null && failure != _reportedRebuildFailure) + { + AddDiagnostic("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; + + DestroyDefaultFramebuffer(); + CreateDefaultFramebuffer((uint)width, (uint)height); + _swapchain.RequestRebuild(_windowWidth, _windowHeight, _vsync); + } + + public void SetVSync(bool enabled) + { + if (_vsync == enabled) return; + _vsync = enabled; + _missedVsyncs.Reset(); + _swapchain?.RequestRebuild(_windowWidth, _windowHeight, _vsync); + } + + private CommandBuffer Commands => _frames.Current.CommandBuffer; + + // -------------------------------------------------------------------- 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. + /// + /// + /// 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) + { + AddDiagnostic($"{stageName}: shader source exceeds {MaxShaderSourceBytes} bytes and was rejected"); + return false; + } + if (shader.Code.IndexOf('\0') >= 0 || (shader.PrefixCode?.IndexOf('\0') ?? -1) >= 0) + { + AddDiagnostic($"{stageName}: shader source contains a NUL byte and was rejected"); + 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) + { + AddDiagnostic($"shader program '{program.PassName}' has no stages"); + return 0; + } + + // 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 && _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; + if (outcome != NativeShaderLibrary.Outcome.Native) native = null; + } + + int programId = 0; + if (native != null) + { + programId = _nextProgramId++; + try + { + resources = new ShaderProgramResources(_context, programId, native, _sharedLayout!.Layout); + } + catch (InvalidOperationException error) + { + nativeFailed = true; + nativeDetail += ": " + error.Message; + } + } + if (nativeFailed) ReportNativeFailure(passName, nativeDetail); + + TranslatedProgram translated; + if (resources != null) + { + translated = native!; + _nativeLinks++; + } + else + { + if (nativeFailed) _failedNativeLinks++; + else _rewrittenLinks++; + + // 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.IsNative ? " native [" + nativeDetail + "]" : "")); + foreach (UniformMember member in translated.Layout.Members) + { + RenderTrace.Write(" uniform " + member.Name + " offset=" + member.Offset + + " type=" + member.Type + " count=" + member.ArrayLength); + } + } + 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); + if (prewarming > 0 && RenderTrace.Enabled) + { + RenderTrace.Write("program " + programId + " prewarming " + prewarming + " pipelines"); + } + 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, + }; + + 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; + 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; + + 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; + _programNames.Remove(programId); + // No background compile may still be reading its modules or layout. + _pipelines.CancelProgram(program); + ForgetNativePipelines(programId); + _frames.DeferDeletion(program); + } + + 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) + { + // 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)) + { + // 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); + } + } + + /// + /// 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 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(); + + 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, 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 }; + 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 + + /// + /// 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. + /// + /// There is deliberately no GPU buffer per block: the snapshot goes in the + /// ring, and the ring-exhausted path allocates its own transient copy for + /// that one draw, so a persistent buffer would only ever sit unbound. + /// + private sealed class ClientUniformBuffer + { + public ClientUniformBuffer(byte[] shadow, string blockName) + { + Shadow = shadow; + BlockName = blockName; + } + + 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; + + /// 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; + } + + private readonly Dictionary _uniformBuffers = 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) + { + int bytes = Math.Max(size, 4); + int id = _nextUniformBufferId++; + _uniformBuffers[id] = new ClientUniformBuffer(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. + if (!string.IsNullOrEmpty(blockName)) _boundUniformBuffers[blockName] = id; + return id; + } + + public void UpdateUniformBuffer(int handle, IntPtr data, int offset, int size) + { + 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; + + ubo.Write(data, offset, size); + } + + public void BindUniformBuffer(int handle) + { + if (_uniformBuffers.TryGetValue(handle, out ClientUniformBuffer? ubo) && ubo.BlockName.Length > 0) + { + _boundUniformBuffers[ubo.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 (!_uniformBuffers.Remove(handle, out ClientUniformBuffer? ubo)) return; + + if (ubo.BlockName.Length > 0 && + _boundUniformBuffers.TryGetValue(ubo.BlockName, out int bound) && bound == handle) + { + _boundUniformBuffers.Remove(ubo.BlockName); + } + } + + // -------------------------------------------------------------------- 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); + RecordGlInternalFormat(id, (int)internalFormat); + + 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); + RecordGlInternalFormat(id, glInternalFormat); + + 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; + } + + /// + /// 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; + } + + /// 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) + { + 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); + 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) + { + 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) + { + FlushPendingClears(textureId); + _textures.GenerateMipmaps(textureId); + } + + 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 once + /// the Frame timeline passed every frame that could name it, 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) + { + 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); + _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 + // past the number of textures that ever existed. + if (texture != null) VulkanStats.NoteTextureDeleted(); + } + + 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 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); + if (texture == null) return 0; + + return parameterName == GlEnums.TextureCompareMode + ? texture.State.CompareEnable ? GlEnums.TextureCompareRefToTexture : GlEnums.TextureCompareModeNone + : 0; + } + + public void UploadTexture2DArrayLayer(int textureId, int layer, int x, int y, + 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) + { + FlushPendingClears(textureId); + _textures.UploadNormalizedShorts(textureId, level, x, y, width, height, pixels); + } + + 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, + // 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; + } + + 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 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 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 DeleteFramebuffer(int framebufferId) + { + _targets.Delete(framebufferId); + FramebufferDeleted?.Invoke(framebufferId); + } + + /// The platform whose graphics this device is; null for a bare device (the GPU tests). + internal Platform.VulkanClientPlatform? OwnerPlatform { get; set; } + + /// + /// 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 + { + 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; + + /// + /// 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 EndStagePass() + { + 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); + } + + /// + /// 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 + + " rgba=" + r + "," + g + "," + b + "," + a); + } + _targets.ClearColor(Commands, attachment, r, g, b, a); + } + + /// The depth clear of an explicit target; the caller has applied the stated depth mask. + internal void ClearNativeDepth(int framebufferId, float depth) + { + if (!BindForNativeClear(framebufferId)) return; + if (RenderTrace.Enabled) RenderTrace.Write("clearDepth target=" + _targets.Bound!.Id + " depth=" + depth); + _targets.ClearDepth(Commands, depth); + } + + 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 + + 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 ? data.Flags.Length * sizeof(int) : 0, + data.IndicesCount * sizeof(int), + data.CustomFloats, data.CustomShorts, data.CustomBytes, data.CustomInts, + data.mode, staticDraw, ssbo: false, signedCustomShorts: true); + + 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); + + /// + /// 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) + { + // 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 && data.XyzCount > 0 && !ssbo) + { + fixed (float* source = data.xyz) + { + _meshes.Write(meshId, MeshManager.BufferXyz, data.XyzOffset, + (IntPtr)source, data.XyzCount * sizeof(float)); + } + } + // 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, data.UvOffset, + (IntPtr)source, data.UvCount * sizeof(float)); + } + } + if (data.Rgba != null && data.RgbaCount > 0) + { + fixed (byte* source = data.Rgba) + { + _meshes.Write(meshId, MeshManager.BufferRgba, data.RgbaOffset, + (IntPtr)source, data.RgbaCount); + } + } + if (data.Flags != null && data.FlagsCount > 0 && !ssbo) + { + fixed (int* source = data.Flags) + { + _meshes.Write(meshId, MeshManager.BufferFlags, data.FlagsOffset, + (IntPtr)source, data.FlagsCount * sizeof(int)); + } + } + if (data.CustomFloats != null && data.CustomFloats.Count > 0) + { + fixed (float* source = data.CustomFloats.Values) + { + _meshes.Write(meshId, MeshManager.BufferCustomFloat, data.CustomFloats.BaseOffset, + (IntPtr)source, data.CustomFloats.Count * sizeof(float)); + } + } + 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, 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 + /// 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); + + // ------------------------------------------------------------------- barriers + + /// + /// 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 SnapshotColorAttachment(CommandBuffer commandBuffer, int textureId, VulkanTexture source) + { + if (_sampledTextureOverrides.ContainsKey(textureId)) return; + + _targets.EndRendering(commandBuffer); + // 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; + + // 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 + { + 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.Require(_barriers, commandBuffer, copy, Graph.ResourceUsage.SampleFragment); + _barriers.Flush(commandBuffer); + _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. + } + + /// + /// 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. A partial submit does not: the frame + /// stays in the same slot, the cursor keeps counting, and the snapshot's bytes + /// are untouched until that slot starts its next frame. + /// + 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; + AddDiagnostic(SanitiseForClientLog(message)); + MirrorValidationMessage(message); + } + + // ----------------------------------------------- shared layout: what is bound + + /// + /// 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 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. + 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) + { + 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) + { + for (int i = 0; i < _frameTextureValues.Length; i++) + { + if (_frameTextureValues[i].Resource != textureId) continue; + _frameTextureValues[i] = default; + _frameTextureIds[i] = 0; + } + } + } + + private static int FrameTextureIndex(int binding) + { + for (int i = 0; i < SetConvention.FrameTextures.Length; i++) + { + if (SetConvention.FrameTextures[i].Value == binding) return i; + } + throw new ArgumentOutOfRangeException(nameof(binding), binding, "not a frame texture binding"); + } + + private static TextureKind KindOf(SamplerBinding sampler) + { + if (!sampler.IsFrameTexture) return sampler.Kind; + BindlessKinds.TryFromGlslType(sampler.TypeName, out TextureKind kind); + return kind; + } + + /// Set 0's placeholder for a frame texture: the bindless table's placeholder of the declared kind. + private SamplerBindingValue FrameTexturePlaceholder(int index) + { + 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); + } + + /// + /// 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) + { + return _frameGlobalsSnapshotOffset; + } + if (!_frames.Current.TryAllocateUniforms(_frameGlobals.Length, out RingAllocation allocation)) + { + ReportUniformExhaustion(program, "the shared frame block"); + return 0; + } + fixed (byte* source = _frameGlobals) + { + System.Buffer.MemoryCopy(source, (void*)allocation.Pointer, _frameGlobals.Length, _frameGlobals.Length); + } + _frameGlobalsSnapshotFrame = _frameCounter; + _frameGlobalsSnapshotVersion = _frameGlobalsVersion; + _frameGlobalsSnapshotOffset = allocation.Offset; + return allocation.Offset; + } + + /// + /// 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. Every native draw binds through here. + /// + 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) + { + uint offset = SnapshotFrameGlobals(program); + var samplers = new SamplerBindingValue[SetConvention.FrameTextures.Length]; + lock (_frameTextureLock) + { + for (int i = 0; i < samplers.Length; i++) + { + samplers[i] = _frameTextureValues[i].View.Handle != 0 ? _frameTextureValues[i] : FrameTexturePlaceholder(i); + } + } + var contents = new DescriptorSetContents(0, SetConvention.FrameSet, samplers, + new[] + { + 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++; + } + } + + // Set 1 once per recording, and the slot indices when they changed. + int pushSize = program.Interface.PushConstantSize; + if (pushSize > 0) + { + if (!_boundTextureSet) + { + DescriptorSet textureSet = _bindless!.Set; + api.CmdBindDescriptorSets(commandBuffer, PipelineBindPoint.Graphics, shared.Layout, + (uint)SetConvention.TextureSet, 1, &textureSet, 0, null); + _boundTextureSet = true; + TextureSetBindsForTests++; + } + if (pushSize > _pushedLength || + !_pushShadow.AsSpan(0, pushSize).SequenceEqual(_pushedBytes.AsSpan(0, pushSize))) + { + fixed (byte* push = _pushShadow) + { + api.CmdPushConstants(commandBuffer, shared.Layout, SharedPipelineLayout.Stages, 0, (uint)pushSize, push); + } + _pushShadow.AsSpan(0, pushSize).CopyTo(_pushedBytes); + _pushedLength = Math.Max(_pushedLength, pushSize); + VulkanStats.NotePushConstantWrite(); + } + } + + if (program.Interface.UsesStorageSet) + { + BindStorageSet(commandBuffer, program, meshId); + } + } + + /// + /// 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 namesRingOffset = false; + uint recordOffset = 0; + const int record = SetConvention.ProgramRecordBinding; + + if (program.Interface.HasUniformBlock) + { + if (program.HasSnapshotFor(_frameCounter)) + { + // Nothing written since this program's last draw this frame took its snapshot. + recordOffset = program.SnapshotOffset; + } + else 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); + } + recordOffset = allocation.Offset; + program.NoteSnapshot(_frameCounter, allocation.Offset); + } + else + { + // 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. + ReportUniformExhaustion(program, "its program record"); + } + buffers[record] = new BufferBindingValue(record, _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 the placeholder's zeroes. + foreach (BlockBinding block in program.Interface.UniformBlocks) + { + ClientUniformBuffer? ubo = null; + if (_boundUniformBuffers.TryGetValue(block.BlockName, out int handle)) _uniformBuffers.TryGetValue(handle, out ubo); + if (ubo == null) continue; + + 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; + } + + // 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. + VulkanStats.NoteUniformOverflow(); + var overflow = new VulkanBuffer(_context, (ulong)ubo.Shadow.Length, + BufferUsageFlags.UniformBufferBit | BufferUsageFlags.StorageBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit); + fixed (byte* shadow = ubo.Shadow) + { + 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); + } + + // 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("storage block '" + block.BlockName + "' on program " + program.ProgramId + + " has no mesh buffer (mesh " + meshId + "); it reads the placeholder"); + } + } + + 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); + 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) + { + 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()); + } + + 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(); + } + + /// + /// Records the dynamic state a draw needs and the recording does not already hold. The + /// 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) + { + 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; + 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 = i < blendStates.Length ? blendStates[i] : AttachmentBlend.Default; + 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); + 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); + + int emitted = DynamicStateCache.CommandCount(dirty & DynamicStateDirty.All) + extraCommands; + _dynamicStateCommands += emitted; + 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; + + /// 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; + + /// 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; + + /// 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; + } + + /// A slot's descriptor arena. Tests only. + internal DescriptorArena DescriptorArenaForTests(int slot) => _descriptorArenas[slot]; + + /// The slot the current (or last) frame records into. Tests only. + internal int CurrentSlotForTests => _frames.Current.Index; + + /// 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); + } + + /// + /// 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; + + 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++; + } + } + + /// 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 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 + /// 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. + /// + /// 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; + + if (_indirectRing.NeedsBuffer(needed, out ulong capacity)) + { + // Nothing recorded names a buffer the slot never had, so creating one is safe mid-frame. + _indirectBuffers[slot] = CreateIndirectBuffer(capacity); + _indirectRing.Attach(capacity); + } + + 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 = _indirectOverflowCursor; + _indirectOverflowCursor += needed; + return current; + } + + // -------------------------------------------------------------------- queries + + private QueryRing _queryRing = null!; + private ReadbackManager _readbacks = null!; + + /// Whether occlusion queries count samples exactly. Tests only. + internal bool PreciseOcclusionForTests => _context.Capabilities.OcclusionQueryPrecise; + + /// Occlusion query pools across every frame slot. Tests only. + internal int OcclusionQueryPoolsForTests => _queryRing.PoolCount; + + public int CreateOcclusionQuery() => _queryRing.Create(); + + public void BeginOcclusionQuery(int queryId) + { + 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. 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 (_queryRing.NextNeedsPool) + { + _targets.EndRendering(commandBuffer); + _queryRing.AddPool(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); + } + + public void EndOcclusionQuery(int queryId) + { + if (_frameActive) _queryRing.End(queryId, _frames.Current.FrameValue, Commands); + } + + /// + /// GL_QUERY_RESULT_AVAILABLE without any wait: true once the Frame timeline + /// passed the command buffer that ended the query, a frame or two later. + /// + public bool IsQueryResultAvailable(int queryId) => _queryRing.IsResultAvailable(queryId); + + /// + /// The samples the latest query counted. Never waits and never submits: the + /// client polls availability first (sun glare does), and a result asked for + /// early returns the previous query's count, or "all visible" if there was + /// none - for a query that gates culling or glare, the cheap failure. + /// + public int GetQueryResult(int queryId) => _queryRing.GetResult(queryId); + + /// + /// Submits everything the frame has recorded so far and keeps recording it in + /// 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() + { + _targets.EndRendering(Commands); + _bindless?.Flush(); + ulong submitted = _frames.SubmitPartial(); + Checkpoint(Commands, CheckpointMarker.FrameBegin(_frameCounter)); + return submitted; + } + + public void DeleteQuery(int queryId) => _queryRing.Delete(queryId); + + // ------------------------------------------------------------------- readback + + /// + /// 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; + byte[] data = ReadBackLevel0(texture); + + bool bgra = texture.Format is Format.B8G8R8A8Unorm or Format.B8G8R8A8Srgb; + bool written = TextureDump.Write(textureId, width, height, bgra, texture.Format, data); + if (written) TextureDump.Complete(textureId); + + RenderTrace.Write("texture dump: " + textureId + " " + width + "x" + height + + " " + texture.Format + " mips=" + texture.MipLevels + " -> " + (written ? "ok" : "failed")); + } + } + + /// + /// Copies level 0 of a texture into host memory, raw texels in the image's own + /// format, rows in memory order (GL order: the backend never flips Y). Inside + /// 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)); + + /// 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; + int height = (int)texture.Height; + ulong bytes = (ulong)width * (ulong)height * (ulong)BytesPerPixel(texture.Format); + ImageAspectFlags aspect = (texture.Aspect & ImageAspectFlags.DepthBit) != 0 + ? ImageAspectFlags.DepthBit + : ImageAspectFlags.ColorBit; + + byte[] data = new byte[bytes]; + fixed (byte* destination = data) + { + ReadBack(texture, 0, 0, (uint)width, (uint)height, aspect, bytes, (IntPtr)destination); + } + return data; + } + + /// + /// The one readback path: screenshots, the texture dump and the parity dump. + /// + /// Inside a frame the open scope closes, the copy is recorded into the frame + /// 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 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) + { + if (_frameActive) + { + _targets.FlushPendingClears(Commands, texture); + _targets.EndRendering(Commands); + ReadbackTicket ticket = _readbacks.CopyToHost(texture, x, y, width, height, aspect, bytes); + SubmitPartial(); + _readbacks.WaitAndCopy(ticket, destination); + return; + } + + // 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, MemoryPoolClass.Staging); + + ImageLayout restore = texture.Layout; + CommandBuffer commandBuffer = _uploads.BeginRecording(inlineInFrame: false); + try + { + _textures.TransitionTexture(commandBuffer, texture, ImageLayout.TransferSrcOptimal); + + var region = new BufferImageCopy + { + ImageSubresource = new ImageSubresourceLayers(aspect, 0, 0, 1), + ImageOffset = new Offset3D(x, y, 0), + ImageExtent = new Extent3D(width, height, 1), + }; + _context.Api.CmdCopyImageToBuffer(commandBuffer, texture.Image, + ImageLayout.TransferSrcOptimal, readback.Handle, 1, ®ion); + + if (restore != ImageLayout.Undefined) _textures.TransitionTexture(commandBuffer, texture, restore); + } + finally + { + _uploads.EndRecording(); + } + ulong transferValue = _uploads.SubmitStandalone(); + _frames.Timeline.WaitForTransfer(transferValue, WaitSite.Readback); + + System.Buffer.MemoryCopy((void*)readback.Mapped, (void*)destination, (long)bytes, (long)handed); + } + + private void RecordGlInternalFormat(int textureId, int glInternalFormat) + { + VulkanTexture? texture = _textures.Get(textureId); + if (texture != null) texture.GlInternalFormat = glInternalFormat; + } + + /// + /// The parity dump's readback (): level 0 in + /// the representation glGetTexImage produces on the OpenGL path, decoded by + /// . Debug only. + /// + public OptimumTextureReadback? ReadTextureForParity(int textureId) + { + if (!_frameActive) return null; + VulkanTexture? texture = _textures.Get(textureId); + if (texture == null || texture.Cube || texture.Layers > 1) return null; + + byte[] data = ReadBackLevel0(texture); + int glInternalFormat = texture.GlInternalFormat != 0 + ? texture.GlInternalFormat + : TextureDump.GlInternalFormatOf(texture.Format); + OptimumTextureReadback? readback = TextureDump.ToParityReadback(texture.Format, glInternalFormat, + (int)texture.Width, (int)texture.Height, data); + RenderTrace.Write("parity dump: texture " + textureId + " " + texture.Width + "x" + texture.Height + + " " + texture.Format + " -> " + (readback != null ? "ok" : "undecodable")); + return readback; + } + + /// + /// Bytes per texel for the formats the dump path is expected to see. + /// Shared with 's decode switch so the + /// readback size and the reader always agree on the stride. + /// + private static int BytesPerPixel(Format format) => TextureDump.BytesPerTexel(format); + + /// 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) + { + EndNativePass(); + ReadFramebufferColor(_targets.Get(ResolveNativeFramebuffer(framebufferId)), x, y, width, height, destination); + } + + 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); + if (texture == null) return; + + ReadBack(texture, x, y, (uint)width, (uint)height, ImageAspectFlags.ColorBit, + (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 + + /// + /// 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 (_pipelinePersistence == null || _pipelines == null) return; + _pipelinePersistence.SaveAtShutdown(_pipelines); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + if (_context != null) + { + 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(); + _compute?.Dispose(); + // 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(); + + _queryRing?.Dispose(); + _readbacks?.Dispose(); + + foreach (VulkanBuffer? indirect in _indirectBuffers) indirect?.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(); + _shaderCompiler?.Dispose(); + _frames?.Dispose(); + _descriptors?.Dispose(); + SavePipelineCache(); + _pipelines?.Dispose(); + _targets?.Dispose(); + _meshes?.Dispose(); + _textures?.Dispose(); + if (_context != null && ReferenceEquals(VulkanStats.MemorySource, _context.Allocator)) + { + VulkanStats.MemorySource = null; + } + DisposeLatency(); + _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/PatchMethodScopes.cs b/Optimum.Tests/PatchMethodScopes.cs new file mode 100644 index 00000000..1fddc410 --- /dev/null +++ b/Optimum.Tests/PatchMethodScopes.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Optimum.Tests; + +/// +/// Per-METHOD attribution for unified diffs. +/// +/// A patch on its own cannot say which method it changed: git's hunk headers for +/// these C# files name the enclosing TYPE (no diff=csharp driver is configured), +/// and three lines of context rarely reach a signature. So a patch file is +/// always paired with the tree it was applied to - the decompiled donor under +/// .build/runtime-donors/, or the mod fork - and every added line is +/// located inside that text, whose method spans this class parses. +/// +/// Both trees are git-ignored, so callers must treat "no source tree" as +/// "cannot check" and fall back to the coarser per-type check rather than +/// failing: a clean clone has neither. +/// +public static class PatchMethodScopes +{ + public sealed record Scope(string Kind, string Name, int Start, int End); + + /// + /// Every brace-delimited scope in , with the method + /// ones named. Comments and string/char literals are skipped so a brace + /// inside them cannot shift the nesting. + /// + public static List Parse(string source) + { + var scopes = new List(); + var stack = new Stack<(string Kind, string Name, int Start)>(); + var header = new StringBuilder(); + + int i = 0; + while (i < source.Length) + { + char c = source[i]; + if (c == '/' && i + 1 < source.Length && source[i + 1] == '/') + { + while (i < source.Length && source[i] != '\n') i++; + header.Append(' '); + continue; + } + if (c == '/' && i + 1 < source.Length && source[i + 1] == '*') + { + i += 2; + while (i + 1 < source.Length && !(source[i] == '*' && source[i + 1] == '/')) i++; + i = Math.Min(i + 2, source.Length); + header.Append(' '); + continue; + } + if (c == '"' || c == '\'') + { + i = SkipLiteral(source, i); + header.Append(' '); + continue; + } + if (c == '{') + { + string parentKind = stack.Count > 0 ? stack.Peek().Kind : "file"; + var scope = Classify(header.ToString(), parentKind); + header.Clear(); + stack.Push((scope.Kind, scope.Name, i)); + i++; + continue; + } + if (c == '}') + { + header.Clear(); + if (stack.Count > 0) + { + var open = stack.Pop(); + scopes.Add(new Scope(open.Kind, open.Name, open.Start, i)); + } + i++; + continue; + } + if (c == ';') + { + header.Clear(); + i++; + continue; + } + header.Append(c); + i++; + } + + return scopes; + } + + /// + /// The innermost method scope containing , or null + /// when the offset is not inside one (a field initializer, a property + /// accessor, a type body). + /// + public static string? MethodAt(IReadOnlyList scopes, int index) + { + Scope? best = null; + foreach (var scope in scopes) + { + if (scope.Kind != "method" || index < scope.Start || index > scope.End) continue; + if (best is null || scope.Start > best.Start) best = scope; + } + return best?.Name; + } + + /// + /// The distinct method names that the patch's added lines land in, located + /// by finding each added line's text in . + /// + public static HashSet MethodsTouched(string patchFile, string source) => + MethodsByAddedLine(patchFile, source, _ => true) + .SelectMany(entry => entry.Value) + .ToHashSet(StringComparer.Ordinal); + + /// + /// marker -> the methods that add it, for every added line carrying one of + /// . + /// + public static Dictionary> MarkersByMethod( + string patchFile, string source, IReadOnlyList markers) + { + var byMethod = new Dictionary>(StringComparer.Ordinal); + foreach (var (line, methods) in MethodsByAddedLine(patchFile, source, _ => true)) + { + foreach (string marker in markers) + { + if (!line.Contains(marker, StringComparison.Ordinal)) continue; + foreach (string method in methods) + { + if (!byMethod.TryGetValue(method, out var set)) + { + byMethod[method] = set = new HashSet(StringComparer.Ordinal); + } + set.Add(marker); + } + } + } + return byMethod; + } + + /// + /// The donor decompile for a type, or null when the donor tree has not been + /// prepared (scripts/prepare-runtime-donors.sh) in this checkout. + /// + public static string? FindDonorSource(string repoRoot, string project, string typeFullName) + { + string root = Path.Combine(repoRoot, ".build", "runtime-donors", project); + if (!Directory.Exists(root)) return null; + string direct = Path.Combine(root, Path.Combine(typeFullName.Split('.')) + ".cs"); + if (File.Exists(direct)) return direct; + string shortName = typeFullName.Split('.').Last(); + return Directory + .EnumerateFiles(root, shortName + ".cs", SearchOption.AllDirectories) + .FirstOrDefault(path => !path.Contains("/obj/", StringComparison.Ordinal) + && !path.Contains("/bin/", StringComparison.Ordinal)); + } + + /// + /// The fork file a patch under patches/<project>/** or + /// patches/runtime/<project>/** was generated from, or null when that + /// tree is not checked out (both are git-ignored). + /// + public static string? FindPatchedTreeFile(string repoRoot, string repoRelativePatch) + { + string trimmed = repoRelativePatch.Replace('\\', '/'); + if (!trimmed.EndsWith(".cs.patch", StringComparison.Ordinal)) return null; + string body = trimmed.Substring(0, trimmed.Length - ".patch".Length); + string candidate = body.StartsWith("patches/runtime/", StringComparison.Ordinal) + ? Path.Combine(repoRoot, ".build", "runtime-donors", + body.Substring("patches/runtime/".Length).Replace('/', Path.DirectorySeparatorChar)) + : body.StartsWith("patches/", StringComparison.Ordinal) + ? Path.Combine(repoRoot, body.Substring("patches/".Length).Replace('/', Path.DirectorySeparatorChar)) + : null; + return candidate != null && File.Exists(candidate) ? candidate : null; + } + + /// + /// Added line -> the methods it occurs in. A line that is pure punctuation or + /// a bare keyword ("{", "try", "return;") is dropped: it occurs everywhere + /// and would attribute a hunk to unrelated methods. + /// + private static List>> MethodsByAddedLine( + string patchFile, string source, Func accept) + { + var scopes = Parse(source); + var result = new List>>(); + foreach (string raw in File.ReadLines(patchFile)) + { + if (!raw.StartsWith("+", StringComparison.Ordinal) || raw.StartsWith("+++", StringComparison.Ordinal)) + { + continue; + } + string text = raw.Substring(1).Trim(); + if (!IsDistinctive(text) || !accept(text)) continue; + + var methods = new HashSet(StringComparer.Ordinal); + for (int at = source.IndexOf(text, StringComparison.Ordinal); at >= 0; + at = source.IndexOf(text, at + 1, StringComparison.Ordinal)) + { + string? method = MethodAt(scopes, at); + if (method != null) methods.Add(method); + } + if (methods.Count > 0) result.Add(new(text, methods)); + } + return result; + } + + private static readonly HashSet Boilerplate = new(StringComparer.Ordinal) + { + "{", "}", "try", "finally", "else", "return;", "break;", "continue;", "});", ")", "};", + }; + + private static bool IsDistinctive(string text) => + text.Length >= 8 && !Boilerplate.Contains(text) && !text.StartsWith("//", StringComparison.Ordinal) + && !text.StartsWith("using ", StringComparison.Ordinal); + + private static readonly Regex TypeDeclaration = + new(@"\b(class|struct|interface|record|enum)\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.Compiled); + + private static readonly Regex MethodName = + new(@"([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^<>()]*>)?\s*$", RegexOptions.Compiled); + + private static (string Kind, string Name) Classify(string header, string parentKind) + { + string text = header.Replace('\n', ' ').Replace('\r', ' ').Trim(); + if (text.StartsWith("namespace ", StringComparison.Ordinal) || text.Contains(" namespace ", StringComparison.Ordinal)) + { + return ("namespace", text); + } + var type = TypeDeclaration.Match(text); + if (type.Success) return ("type", type.Groups[2].Value); + + if (parentKind == "type" || parentKind == "namespace" || parentKind == "file") + { + int paren = text.IndexOf('('); + if (paren > 0) + { + var name = MethodName.Match(text.Substring(0, paren).TrimEnd()); + if (name.Success) return ("method", name.Groups[1].Value); + } + return ("other", text); + } + return ("block", string.Empty); + } + + private static int SkipLiteral(string source, int start) + { + char quote = source[start]; + bool verbatim = start > 0 && source[start - 1] == '@' && quote == '"'; + int i = start + 1; + while (i < source.Length) + { + char c = source[i]; + if (verbatim) + { + if (c == '"') + { + if (i + 1 < source.Length && source[i + 1] == '"') { i += 2; continue; } + return i + 1; + } + } + else + { + if (c == '\\') { i += 2; continue; } + if (c == quote) return i + 1; + if (c == '\n') return i + 1; + } + i++; + } + return source.Length; + } +} diff --git a/Optimum.Tests/ambient-occlusion-coverage-tests.cs b/Optimum.Tests/ambient-occlusion-coverage-tests.cs new file mode 100644 index 00000000..99ea610e --- /dev/null +++ b/Optimum.Tests/ambient-occlusion-coverage-tests.cs @@ -0,0 +1,621 @@ +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"); + // 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); + 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 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, + "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")); + + // 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 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"); + 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/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() ?? ""); + } + + /// + /// 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 TheThinClassIsTheVertexWindFlagAndTheComposeDropsTheRowMin() + { + 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); + 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))); + } + + // ------------------------------------------------------------------ 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 TheAoChoiceIsOneDropDownWiredToTheConfig() + { + 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); + // One control for the whole choice: off, the game's own pass, ours, or auto. + Assert.Contains("AddDropDown(new string[] { \"off\", \"auto\", \"vanilla\", \"gtao\" }", gui); + Assert.Contains("onOptimumAmbientOcclusionChanged", gui); + Assert.Contains("\"optAoMode\")", gui); + Assert.Contains("composer.GetDropDown(\"optAoMode\").SetSelectedIndex(", gui); + foreach (string key in new[] { "optimum-ao-off", "optimum-ao-auto", "optimum-ao-vanilla", "optimum-ao-gtao" }) + { + Assert.Contains("Lang.Get(\"" + key + "\")", gui); + } + + string handler = Between(gui, "private void onOptimumAmbientOcclusionChanged(string code, bool selected)", "\n\t}"); + Assert.Contains("OptimumConfig.AmbientOcclusionEnabled = false;", handler); + Assert.Contains("OptimumConfig.AmbientOcclusion = code;", handler); + Assert.Contains("OptimumConfig.Save();", handler); + // Off gates the passes only, so it must not reload; changing WHICH AO runs changes the + // OPTIMUMAO the shaders carry, so exactly that case reloads them. + Assert.Contains("bool wasGtao = Vintagestory.API.Config.OptimumConfig.EffectiveGtao;", handler); + Assert.Contains("if (Vintagestory.API.Config.OptimumConfig.EffectiveGtao != wasGtao)", handler); + Assert.Contains("handler.ReloadShaders();", handler); + Assert.DoesNotContain("RebuildFrameBuffers", handler); + Assert.DoesNotContain("RequestReset", handler); + } + + [Fact] + public void TheMasterSwitchHasItsLangEntriesAndItsPatcherListing() + { + string lang = Read("sources/lang/en.json"); + foreach (string key in new[] + { + "optimum-ao", "optimum-ao-tooltip", + "optimum-ao-off", "optimum-ao-auto", "optimum-ao-vanilla", "optimum-ao-gtao", + }) + { + Assert.Contains("\"" + key + "\":", lang); + } + 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")); + } + + // ------------------------------------- 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); + 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/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(" +/// 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/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..ca0c0df2 --- /dev/null +++ b/Optimum.Tests/client-platform-windows-vanilla-regions-tests.cs @@ -0,0 +1,267 @@ +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", "OptimumSceneNoHudIndex", "OptimumUiTargetIndex", "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", "OptimumTaaResolveDraw", "OptimumTaaSharpenDraw", + "OptimumPostAmbientOcclusionTexture", "OptimumPostSsaoInScene", "OptimumPostSsaaLevel", + // 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", + "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", + "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. + "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), + // 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", + // Upstream's GPU indirect draw submission (issue #75: RenderMesh's multi-draw form and its + // injected indirect-buffer fields) and its texture upload changes, merged 2026-09-17. + "RenderMesh", "_optimumSharedIndirectCommands", "_optimumSingleIndirectBufferId", + "_optimumSingleIndirectBufferCapacity", "LoadIntoTexture", "LoadTexture", + + // 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/color-write-tier-coverage-tests.cs b/Optimum.Tests/color-write-tier-coverage-tests.cs new file mode 100644 index 00000000..a411bf61 --- /dev/null +++ b/Optimum.Tests/color-write-tier-coverage-tests.cs @@ -0,0 +1,78 @@ +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); + + 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() + { + // 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"); + Assert.DoesNotContain("DrawBufferMask", targets); + Assert.DoesNotContain("SampledExclusion", targets); + Assert.Contains("VulkanStats.NoteMaskRestart();", 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); + } + + 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/Optimum.Tests/frame-graph-coverage-tests.cs b/Optimum.Tests/frame-graph-coverage-tests.cs new file mode 100644 index 00000000..2b801b06 --- /dev/null +++ b/Optimum.Tests/frame-graph-coverage-tests.cs @@ -0,0 +1,116 @@ +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, 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);", Read("Optimum.Render.Vulkan/VulkanDevice.Native.cs")); + 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("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); + } + + [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("PassFlags.OpenSampling | PassFlags.AllowSplit", graph); + // 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); + 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} push_constants={19} storage_set_binds={20} \"", stats); + Assert.Contains("compute_passes={23} dispatches={24}", 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)); + } +} diff --git a/Optimum.Tests/fsr-pipeline-coverage-tests.cs b/Optimum.Tests/fsr-pipeline-coverage-tests.cs index 3d9c2956..89019cb9 100644 --- a/Optimum.Tests/fsr-pipeline-coverage-tests.cs +++ b/Optimum.Tests/fsr-pipeline-coverage-tests.cs @@ -63,6 +63,61 @@ 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 && UseNativePostChain)", 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); + // 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); + + // 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() { @@ -73,14 +128,58 @@ public void TerrainBiasCoversTextureObjectsAndCustomSamplers() "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); - // Bias must be skipped entirely at native res (RenderScale >= 1.0) so - // rendering matches vanilla exactly - vanilla never sets these - // 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); - Assert.Contains("if (OptimumConfig.EffectiveRenderScale < 1.0f)", shaderRegistry); - Assert.Contains("(SamplerParameterName)34049, terrainLodBias", shaderRegistry); + // Bias must be skipped entirely when nothing asks for one (native res + // with TAA off, which is the only configuration that made a bias before + // P5 added TaaMipBias to the same value) so rendering matches vanilla + // exactly - vanilla never sets these TexParameter/SamplerParameter + // calls at all. + Assert.Contains("float textureLodBias = Vintagestory.API.Config.OptimumConfig.EffectiveTerrainLodBias;", chunkRenderer); + Assert.Contains("if (textureLodBias == 0f)", chunkRenderer); + // ... but "no call" only holds once the bias has been cleared again. The + // cache starts at NaN and the zero branch is a RESTORE path: after a + // nonzero bias it writes 0 back through SetOptimumTextureLodBias (atlas + // TexParameter and, via ShaderRegistry, the terrain sampler objects) + // before it returns and resets the cache to NaN. Without that a user who + // turns TAA off, or leaves FSR, would keep the last bias until the next + // shader reload. + Assert.Contains("private float optimumTextureLodBias = float.NaN;", chunkRenderer); + string zeroBranch = BranchAfter(chunkRenderer, "if (textureLodBias == 0f)"); + Assert.Contains("if (!float.IsNaN(optimumTextureLodBias))", zeroBranch); + Assert.Contains("SetOptimumTextureLodBias(0f);", zeroBranch); + Assert.Contains("optimumTextureLodBias = float.NaN;", zeroBranch); + // ...and the branch really is just that branch: the nonzero path below + // it is outside it. + Assert.DoesNotContain("SetOptimumTextureLodBias(textureLodBias)", zeroBranch); + // The render-scale term itself still is log2 of the clamped scale; it + // now lives in OptimumConfig so both call sites share it. + string optimumConfig = Read("VintagestoryApi/Config/OptimumConfig.cs"); + Assert.Contains("bias += MathF.Log2(Math.Clamp(scale, 0.5f, 1.0f));", optimumConfig); + // 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); + // 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 + // ApplyOptimumTerrainSamplerLodBias so ChunkRenderer can reach them too + // (a bound sampler object overrides the atlas TexParameter, so a live + // bias change has to write both). The load still passes the same value + // and still only when it is non-zero. + Assert.Contains("ApplyOptimumTerrainSamplerLodBias(terrainLodBias);", shaderRegistry); + // The sampler entry point itself applies the RAW value: no "!= 0f" + // short-circuit inside it, or the restore path above would reach the + // atlas parameter and leave the two sampler objects biased. + string samplerEntry = MethodBody(shaderRegistry, "public static void ApplyOptimumTerrainSamplerLodBias(float bias)"); + Assert.DoesNotContain("!= 0f", samplerEntry); + Assert.Equal(4, Count(samplerEntry, "ApplyOptimumSamplerLodBias(")); + Assert.Equal(4, Count(samplerEntry, ", bias);")); + 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); } @@ -121,6 +220,40 @@ public void MipBiasMatchesRenderScale(float scale, float expected) Assert.InRange(MathF.Log2(scale), expected - 0.001f, expected + 0.001f); } + /// + /// The body of the brace-delimited block that follows . + /// + private static string BranchAfter(string source, string header) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "branch not found: " + header); + return Block(source, start + header.Length); + } + + /// + /// The text of one method, signature included, up to its matching brace. + /// + private static string MethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "method not found: " + signature); + return signature + Block(source, start + signature.Length); + } + + private static string Block(string source, int offset) + { + int open = source.IndexOf('{', offset); + Assert.True(open > offset - 1, "no block after offset " + offset); + 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); + } + Assert.Fail("unbalanced block after offset " + offset); + return string.Empty; + } + private static int Count(string source, string value) { int count = 0; diff --git a/Optimum.Tests/headless-harness-coverage-tests.cs b/Optimum.Tests/headless-harness-coverage-tests.cs new file mode 100644 index 00000000..f76bf90e --- /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.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); + + // ... 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 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( + "private void ReadFramebufferColor(VulkanFramebuffer? target, int x, int y, int width, int height, IntPtr destination)", + StringComparison.Ordinal); + Assert.True(deviceAt > 0, "VulkanDevice.ReadFramebufferColor 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/Optimum.Tests/installer-release-coverage-tests.cs b/Optimum.Tests/installer-release-coverage-tests.cs index 67249188..94d2ba41 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/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/mod-pass-api-coverage-tests.cs b/Optimum.Tests/mod-pass-api-coverage-tests.cs new file mode 100644 index 00000000..d176bcc8 --- /dev/null +++ b/Optimum.Tests/mod-pass-api-coverage-tests.cs @@ -0,0 +1,259 @@ +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("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); + 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/Optimum.Tests/mod-patcher-manifest-consistency-tests.cs b/Optimum.Tests/mod-patcher-manifest-consistency-tests.cs index 637615c4..9c75ec28 100644 --- a/Optimum.Tests/mod-patcher-manifest-consistency-tests.cs +++ b/Optimum.Tests/mod-patcher-manifest-consistency-tests.cs @@ -121,6 +121,138 @@ public void EveryInjectedInterfaceIsDeclaredInItsRuntimePatch(string manifestMet Assert.True(problems.Count == 0, FormatFailure(manifestMethodName, problems)); } + /// + /// Methods entries are the transplants themselves: Cecil copies each named + /// body out of the compiled donor into the user's own mod assembly. A method + /// listed here whose declaring type has no runtime patch (and no + /// Optimum-authored source overlay) is transplanted from an unmodified + /// decompile, so the installed runtime silently keeps the vanilla body while + /// the from-source fork build has the changed one - which is exactly how the + /// TAA P3/P4 movers shipped ghosting for installed players until + /// patches/runtime gained donors for them. + /// + [Theory] + [MemberData(nameof(Manifests))] + public void EveryTransplantedMethodHasARuntimeDonor(string manifestMethodName, string project) + { + var methods = GetManifestProperty>(manifestMethodName, "Methods"); + + // Coverage is per METHOD where it can be: a runtime patch that touches + // one method of a type used to mark every transplant on that type as + // covered, so a sibling method kept its vanilla body silently. The + // patch's added lines are located inside the tree it was applied to + // (.build/runtime-donors, else the fork, both git-ignored) and attributed + // to the method whose braces enclose them. With neither tree on disk - + // a clean clone - the check degrades to the old per-type one. + var uncovered = new List(); + var unchangedBody = new List(); + foreach (var target in methods) + { + string shortName = ShortName(target.TypeFullName); + string entry = $"{target.TypeFullName}::{target.MethodName}"; + string? patchFile = FindPatchFile(project, shortName); + if (patchFile is not null) + { + string? donor = PatchMethodScopes.FindDonorSource(RepoRoot(), project, target.TypeFullName) + ?? FindForkSource(project, shortName); + if (donor is null) + { + continue; + } + string donorText = File.ReadAllText(donor); + var touched = PatchMethodScopes.MethodsTouched(patchFile, donorText); + // ".ctor" is the IL name; the source declares it under the type + // name. A method the scanner cannot find at all (an accessor, a + // local function, a shape this scanner does not model) is left + // to the per-type check rather than reported as a gap. + string sourceName = target.MethodName == ".ctor" ? shortName : target.MethodName; + if (!touched.Contains(sourceName) && DeclaresMethod(donorText, sourceName)) + { + unchangedBody.Add(entry); + } + continue; + } + // Optimum-authored types are copied into the donor tree whole by + // scripts/prepare-runtime-donors.sh, so they never get a patch. + if (FindSourceFile(shortName) is not null) + { + continue; + } + uncovered.Add(entry); + } + + var untouched = unchangedBody + .Where(entry => !KnownUnchangedTransplants.Contains(entry)) + .OrderBy(entry => entry, StringComparer.Ordinal) + .ToList(); + Assert.True( + untouched.Count == 0, + FormatFailure( + manifestMethodName, + untouched + .Select(entry => + $"{entry}: the type has a patches/runtime/{project} donor, but no hunk in it lands " + + "inside this method, so the transplant copies a body the donor never changed. Either " + + "the donor patch is behind the fork (the installed runtime then ships a vanilla body) " + + "or the manifest entry is redundant - decide which and record it in " + + "KnownUnchangedTransplants if it is the latter.") + .ToList())); + + var unexpected = uncovered + .Where(entry => !KnownDonorGaps.Contains(entry)) + .OrderBy(entry => entry, StringComparer.Ordinal) + .ToList(); + Assert.True( + unexpected.Count == 0, + FormatFailure( + manifestMethodName, + unexpected + .Select(entry => + $"{entry}: no patches/runtime/{project}/**/*.cs.patch and no sources/** overlay produces " + + "this type, so the installed runtime transplants a vanilla body.") + .ToList())); + + var closed = KnownDonorGaps + .Where(entry => methods.Any(m => $"{m.TypeFullName}::{m.MethodName}" == entry) && !uncovered.Contains(entry)) + .OrderBy(entry => entry, StringComparer.Ordinal) + .ToList(); + Assert.True( + closed.Count == 0, + "These entries now have runtime donors; remove them from KnownDonorGaps:\n " + + string.Join("\n ", closed)); + } + + /// + /// Transplants whose declaring type has no runtime donor today, listed so + /// that the gap is visible and a *new* one still fails the test. Both are + /// Vulkan-backend work on the FluffyClouds renderers (a separate assembly + /// that ships inside VSEssentials.dll); neither is TAA. + /// + /// + /// Transplants whose declaring type IS patched but whose own body no hunk + /// touches. Copying an unchanged body is a no-op, so these are redundant + /// manifest entries rather than ghosting gaps - but a NEW one is how a donor + /// patch falls behind its fork, which is why they are listed rather than + /// ignored. + /// + private static readonly HashSet KnownUnchangedTransplants = new(StringComparer.Ordinal) + { + // ChunkMapLayer's map-piece caching changed every caller of + // loadFromChunkPixels; the two-line method itself (enqueue onto the + // vanilla readyMapPieces) is untouched in both trees. + "Vintagestory.GameContent.ChunkMapLayer::loadFromChunkPixels", + }; + + private static readonly HashSet KnownDonorGaps = new(StringComparer.Ordinal) + { + "FluffyClouds.CloudRendererMap::FreeGlResources", + "FluffyClouds.CloudRendererMap::OnRenderFrame", + "FluffyClouds.CloudRendererMap::WriteTexture", + "FluffyClouds.CloudRendererMap::makeTexture", + "FluffyClouds.CloudRendererMap::InitCloudTiles", + "FluffyClouds.CloudRendererVolumetric::OnRenderFrame", + }; + private static string FormatFailure(string manifestMethodName, List problems) => $"ModPatcher.{manifestMethodName} is out of sync with the runtime patches:\n " + string.Join("\n ", problems); @@ -146,6 +278,28 @@ private static string RepoRoot() return Path.GetDirectoryName(versionFile)!; } + private static bool DeclaresMethod(string source, string methodName) => + PatchMethodScopes.Parse(source) + .Any(scope => scope.Kind == "method" && scope.Name == methodName); + + // The fork tree carries the same edits as the prepared donor decompile, so + // it stands in when .build/runtime-donors has not been built. File names + // differ from type names there, so this greps for the declaration. + private static string? FindForkSource(string project, string shortTypeName) + { + string dir = Path.Combine(RepoRoot(), project); + if (!Directory.Exists(dir)) + { + return null; + } + var declaration = new Regex($@"\b(class|struct|record)\s+{Regex.Escape(shortTypeName)}\b"); + return Directory + .EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories) + .Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .FirstOrDefault(file => declaration.IsMatch(File.ReadAllText(file))); + } + private static string? FindPatchFile(string project, string shortTypeName) { string dir = Path.Combine(RepoRoot(), "patches", "runtime", project); 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..5fe8f51b --- /dev/null +++ b/Optimum.Tests/native-post-chain-coverage-tests.cs @@ -0,0 +1,475 @@ +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"; + 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 + /// 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 pass of the chain draws natively, and each pass whose OpenGL body is a legacy helper + /// keeps that helper as the old route the differential tests compare against. + /// + [Fact] + public void EveryChainStepIsNativeAndKeepsItsOldRouteReachable() + { + string chain = Read(ChainFile); + + foreach (string helper in new[] + { + "private bool PostStepTaaResolve() => RenderOptimumTaaResolve();", + "private int PostStepTaaSharpen(int resolvedScene) => RenderOptimumTaaSharpen(resolvedScene);", + "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()", + }) + { + Assert.Contains(helper, chain); + } + + // Every pass of the chain is native now: no step carries a "Stage 1x makes it native" + // marker any more. + Assert.DoesNotContain("makes it native", chain); + // One LEGACY helper per native pass whose OpenGL body stays reachable for the + // differential tests: the merge, sky motion, bloom, god rays, the Luma step and the + // final composition. The AO step and the two TAA passes keep their old route in the lib + // virtual itself, not in a legacy helper. + Assert.Equal(6, 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); + } + } + + /// + /// 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( + "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; + } + + /// + /// 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); + 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/native-world-systems-coverage-tests.cs b/Optimum.Tests/native-world-systems-coverage-tests.cs new file mode 100644 index 00000000..e5fcc06f --- /dev/null +++ b/Optimum.Tests/native-world-systems-coverage-tests.cs @@ -0,0 +1,1036 @@ +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 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 ChunkPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeChunks.cs"; + private const string WorldPlatformFile = "Optimum.Render.Vulkan/Platform/VulkanClientPlatform.NativeWorld.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"; + + 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, + /// 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); + + // 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); + 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 = RenderLimits.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); + } + + /// + /// 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); + + // 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); + 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("TryDrawStated(vAO, 1, indices, indicesSizes, groupCount);", 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); + } + + // ------------------------------------------------------------- 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); + + // 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); + 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("NoteNativeProgramTexture(program.ProgramId, samplerName, textureId);", shaders); + + string native = Read(DeviceNativeFile); + Assert.Contains("internal string[] SamplerNames { get; }", 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 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); + + // 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( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + foreach (string seam in new[] + { + "RenderNightSkyBox", "RenderCelestialQuad", "RenderSunQuad", "RenderParticles", + "BeginDecalPass", "EndDecalPass", + }) + { + 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); + // 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", + "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 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.BeginDecalPass(decalTextureAtlas.TextureId, game.BlockAtlasManager.AtlasTextures[0].TextureId);", + 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); + } + + /// 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", "RenderSunQuad", "RenderParticles", + "BeginDecalPass", "EndDecalPass", + }) + { + 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); + + // 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", "RenderSunQuad", "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); + 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); + } + + // ------------------------------------------------------- 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\", 10", 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); + + // 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); + Assert.Contains("base.RenderOverlayLines(", gui); + Assert.Contains("device.BeginNativePass(", 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 + // factor table, the caller's line width, and the mesh's own topology and layout. + // 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); + // 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); + } + + /// + /// 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/PipelineState.cs"); + + Assert.Contains("public static AttachmentBlend For(bool enabled, EnumBlendMode mode)", tracker); + Assert.Contains("FactorsFor(EnumBlendMode mode) => mode switch", 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); + Assert.True(first >= 0); + Assert.Equal(-1, tracker.IndexOf("EnumBlendMode.PremultipliedAlpha =>", first + 1, StringComparison.Ordinal)); + } + + 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) + { + 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; + } + } + /// + /// 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); + } + /// + /// 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); + } + /// + /// 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); + } + /// + /// Plain draws under the vanilla standard program go native from RenderMesh under the stated + /// 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 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.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); + Assert.Contains("statedColorMaskOff =", + Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.State.cs")); + 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("ReferenceEquals(program, ShaderPrograms.Guigear)", gui); + 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("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); + } + /// + /// 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 EveryRemainingDrawTakesTheGenericStatedRoute() + { + string meshes = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.Meshes.cs"); + 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); + + 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(programId, names[i]);", route); + Assert.Contains("reads[i] = stated.TextureAt(units[i]);", 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); + 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/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/pacing-log-format-coverage-tests.cs b/Optimum.Tests/pacing-log-format-coverage-tests.cs new file mode 100644 index 00000000..a944beac --- /dev/null +++ b/Optimum.Tests/pacing-log-format-coverage-tests.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The OPTIMUM_FPS_LOG line has one producer (ClientMain.OptimumLogFrameTime) and +/// three readers: perf-capture.sh, pacing-gate.sh and the acceptance document. +/// These tests render the producer's own format string and make every reader +/// agree with it, so a field added on one side cannot silently fall off another +/// (perf-capture.sh's Vulkan-stats regex had already drifted that way once). +/// +public class PacingLogFormatCoverageTests +{ + private static readonly string[] FpsKeys = { "window", "frames", "mean", "min", "max", "p99", "stddev" }; + + private static string ClientMain() => ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientMain.cs"); + + private static string FpsFormatString() + { + Match match = Regex.Match(ClientMain(), "\"(\\[Optimum\\] fps window=[^\"]*)\""); + Assert.True(match.Success, "no [Optimum] fps format string in ClientMain"); + return match.Groups[1].Value; + } + + private static string ScriptFpsRegex(string script) + { + Match match = Regex.Match(Read(script), "FPS_LINE_RE = re\\.compile\\(r\"(.*)\"\\)"); + Assert.True(match.Success, "no single-line FPS_LINE_RE in " + script); + // Python spells a named group (?P...); .NET spells it (?...). + return match.Groups[1].Value.Replace("(?P<", "(?<", StringComparison.Ordinal); + } + + private static List Keys(string text, string pattern) + { + var keys = new List(); + foreach (Match match in Regex.Matches(text, pattern)) keys.Add(match.Groups[1].Value); + return keys; + } + + [Fact] + public void FpsLineFormatAppendsStddevAfterP99() + { + string format = FpsFormatString(); + Assert.Equal( + "[Optimum] fps window={0:F3} frames={1} mean={2:F3} min={3:F3} max={4:F3} p99={5:F3} stddev={6:F3}", + format); + Assert.Equal(FpsKeys, Keys(format, @"(\w+)=\{")); + } + + [Fact] + public void ClientStddevIsComputedOverTheSameWindowWithoutHelperTypes() + { + string clientMain = ClientMain(); + Assert.Contains("double stddev = Math.Sqrt(squares / (double)sampled);", clientMain); + // Seven loose arguments would bind string.Format's params ReadOnlySpan + // overload, which lowers into an InlineArray helper type the Cecil + // transplant cannot carry. + Assert.Contains("object[] fields = new object[7];", clientMain); + Assert.Contains("fields[6] = stddev;", clientMain); + + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"OptimumLogFrameTime\"", patcher); + Assert.Contains("\"Vintagestory.Client.NoObf.ClientMain\", \"MainRenderLoop\", 1", patcher); + } + + [Fact] + public void BothScriptsParseTheRenderedLineAndOldLogs() + { + string format = FpsFormatString(); + string rendered = string.Format(CultureInfo.InvariantCulture, format, + 1.004, 120, 8.367, 7.912f, 11.204f, 10.811f, 0.612); + string old = rendered.Substring(0, rendered.IndexOf(" stddev=", StringComparison.Ordinal)); + + string capture = ScriptFpsRegex("scripts/dev/perf-capture.sh"); + string gate = ScriptFpsRegex("scripts/dev/pacing-gate.sh"); + Assert.Equal(capture, gate); + Assert.Equal(FpsKeys, Keys(gate, @"\(\?<(\w+)>")); + + Match match = Regex.Match("2026-09-11 " + rendered, gate); + Assert.True(match.Success, rendered); + Assert.Equal("1.004", match.Groups["window"].Value); + Assert.Equal("120", match.Groups["frames"].Value); + Assert.Equal("10.811", match.Groups["p99"].Value); + Assert.Equal("0.612", match.Groups["stddev"].Value); + + Match oldMatch = Regex.Match(old, gate); + Assert.True(oldMatch.Success, old); + Assert.False(oldMatch.Groups["stddev"].Success); + } + + [Fact] + public void AcceptanceDocumentShowsTheSameFieldsInTheSameOrder() + { + string doc = Read("docs/taa-acceptance.md"); + string? template = null; + foreach (string line in doc.Split('\n')) + { + if (line.Contains("[Optimum] fps window=<", StringComparison.Ordinal)) + { + template = line; + break; + } + } + Assert.NotNull(template); + Assert.Equal(FpsKeys, Keys(template!, @"(\w+)=<")); + + Assert.Contains("scripts/dev/pacing-gate.sh", doc); + foreach (string rule in new[] + { "blocking_uploads", "stddev_vs_baseline", "p99_vs_mean", "dropped_mesh_writes", "uniform_overflows" }) + { + Assert.Contains("`" + rule + "`", doc); + } + } + + [Fact] + public void PerfCapturePrintsStddevAndReadsTheRealStatsText() + { + string script = Read("scripts/dev/perf-capture.sh"); + Assert.Contains("frame stddev", script); + Assert.Contains("stddev_ms", script); + // VulkanStats writes "N frames (M ms/frame)"; there never was a frameMs token. + Assert.Contains(@"ms/frame\)", script); + Assert.DoesNotContain("frameMs[= ]", script); + Assert.Contains("scripts/dev/pacing-gate.sh", script); + } + + [Fact] + public void PacingGateSelfTestPasses() + { + (int code, string output) = RunGate("--self-test"); + Assert.True(code == 0, output); + Assert.Contains("cases passed", output); + Assert.DoesNotContain("FAIL ", output); + } + + [Fact] + public void PacingGateAcceptsTheClientLineOnAnOpenGlRunWithoutStats() + { + string format = FpsFormatString(); + string dir = Directory.CreateTempSubdirectory("optimum-pacing-").FullName; + try + { + string fps = Path.Combine(dir, "fps.log"); + string line = string.Format(CultureInfo.InvariantCulture, format, + 1.004, 120, 8.367, 7.912f, 11.204f, 10.811f, 0.612); + File.WriteAllText(fps, line + "\n" + line + "\n" + line + "\n"); + + (int code, string output) = RunGate("--renderer", "opengl", "--fps", fps, "--baseline", fps); + Assert.True(code == 0, output); + Assert.Contains("median stddev 0.612 ms", output); + Assert.Matches(new Regex(@"stddev_vs_baseline\s+0\.612 ms.*PASS"), output); + Assert.Matches(new Regex(@"blocking_uploads\s+-\s+no --stats\s+SKIP"), output); + + // A Vulkan run is not gateable without its stats file. + (code, output) = RunGate("--renderer", "vulkan", "--fps", fps); + Assert.True(code == 2, output); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void PacingGateNeverPatternKills() + { + string script = Read("scripts/dev/pacing-gate.sh"); + Assert.DoesNotContain("pkill", script); + Assert.DoesNotContain("pgrep", script); + Assert.Contains("\nset -euo pipefail\n", script); + + string capture = Read("scripts/dev/perf-capture.sh"); + Assert.Contains("\nset -euo pipefail\n", capture); + // Under pipefail a missing renderer line must reach the refusal, not end the script. + Assert.Contains("awk '{print $2}' || true)\"", capture); + } + + private static (int Code, string Output) RunGate(params string[] arguments) + { + string script = PatchReader.FindRepositoryFile("scripts/dev/pacing-gate.sh"); + var start = new ProcessStartInfo("bash") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add(script); + foreach (string argument in arguments) start.ArgumentList.Add(argument); + + using Process process = Process.Start(start)!; + string stdout = process.StandardOutput.ReadToEnd(); + string stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdout + stderr); + } + + 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/Optimum.Tests/parity-dump-coverage-tests.cs b/Optimum.Tests/parity-dump-coverage-tests.cs new file mode 100644 index 00000000..3c93107d --- /dev/null +++ b/Optimum.Tests/parity-dump-coverage-tests.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Phase 0 per-attachment parity dump (OPTIMUM_PARITY_DUMP): source coverage for +/// the patched platform, the shared API writer, the Vulkan readback, the capture +/// script, ssim.py and the acceptance documents. +/// +public class ParityDumpCoverageTests +{ + private const string PlatformPatch = "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch"; + private const string PlatformSource = "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"; + + /// EnumFrameBuffer as vanilla ships it; cross-checked against the API file when present. + private static readonly Dictionary EnumFrameBufferValues = new() + { + ["Primary"] = 0, ["Transparent"] = 1, ["BlurHorizontalMedRes"] = 2, ["BlurVerticalMedRes"] = 3, + ["FindBright"] = 4, ["LiquidDepth"] = 5, ["GodRays"] = 7, ["BlurVerticalLowRes"] = 8, + ["BlurHorizontalLowRes"] = 9, ["Luma"] = 10, ["ShadowmapFar"] = 11, ["ShadowmapNear"] = 12, + ["SSAO"] = 13, ["SSAOBlurVertical"] = 14, ["SSAOBlurHorizontal"] = 15, + ["SSAOBlurVerticalHalfRes"] = 16, ["SSAOBlurHorizontalHalfRes"] = 17, + }; + + [Fact] + public void DumpedSlotListMatchesTheFramebuffersBothSetupsCreate() + { + string platform = ReadSourceOrPatched(PlatformPatch, PlatformSource); + Dictionary constants = Constants(platform); + + string glBody = MethodBody(platform, "public virtual List SetupDefaultFrameBuffers()"); + // 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); + SortedSet deviceSlots = AssignedSlots(deviceBody, constants); + Assert.Equal(glSlots, deviceSlots); + + var named = new SortedDictionary(); + foreach (Match match in Regex.Matches(namesBody, @"case\s+(\w+):\s*return\s+""(\w+)"";")) + { + int slot = int.TryParse(match.Groups[1].Value, out int literal) ? literal : constants[match.Groups[1].Value]; + named.Add(slot, match.Groups[2].Value); + } + // 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); + // 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(); + foreach (KeyValuePair slot in named) + { + if (enumValues.TryGetValue(slot.Value, out int value)) Assert.Equal(value, slot.Key); + else Assert.StartsWith("Optimum", slot.Value); + } + Assert.Equal("OptimumFsr", named[constants["OptimumFsrFramebufferIndex"]]); + Assert.Equal("OptimumTaaHistoryA", named[constants["OptimumTaaHistoryIndexA"]]); + Assert.Equal("OptimumTaaHistoryB", named[constants["OptimumTaaHistoryIndexB"]]); + Assert.Equal("OptimumTaaSharpen", named[constants["OptimumTaaSharpenIndex"]]); + + // The dump walks the whole framebuffer list, colour and depth, so a slot + // added to the setups is dumped even before it gets a name. + string dump = MethodBody(platform, "private void OptimumRunParityDump()"); + Assert.Contains("List list = frameBuffers;", dump); + Assert.Contains("slot < list.Count", dump); + Assert.Contains("frameBuffer.ColorTextureIds.Length", dump); + Assert.Contains("frameBuffer.DepthTextureId", dump); + Assert.Contains("\"color\" + attachment", dump); + Assert.Contains("\"depth\"", dump); + Assert.Contains("logger.Notification(\"[Optimum] parity dump: \" + attachments + \" attachments -> \" + directory);", dump); + } + + [Fact] + 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("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("); + // 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(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)")); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + string dump = Read("Optimum.Render.Vulkan/Core/TextureDump.cs"); + Assert.Contains("public OptimumTextureReadback? ReadTextureForParity(int textureId)", device); + Assert.Contains("TextureDump.ToParityReadback(", device); + foreach (string source in new[] { device, dump }) + { + Assert.DoesNotContain("FileNameFormat", source); + Assert.DoesNotContain("{0}-{1}-{2}-{3}", source); + Assert.DoesNotContain("OptimumParityDump.Write", source); + } + } + + [Fact] + public void EnvUnsetCostsOneStaticBoolCheckPerFrame() + { + string api = ReadApi(); + Assert.Contains("public static readonly bool Enabled = Directory != null;", api); + Assert.Contains("Environment.GetEnvironmentVariable(\"OPTIMUM_PARITY_DUMP\")", api); + Assert.Contains("Environment.GetEnvironmentVariable(\"OPTIMUM_PARITY_FRAME\")", api); + + 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{\n\t\t\tOptimumRunParityDump();"; + string normalized = frame.Replace("\r\n", "\n"); + // 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( + "patches/VintagestoryLib/Vintagestory.Client/ScreenManager.cs.patch", + "build/VintagestoryLib/Vintagestory.Client/ScreenManager.cs"); + Assert.Contains("Platform.BlitPrimaryToDefault();", MethodBody(screenManager, "internal void Render(float dt)")); + + // Frames count only once the player is in the world, and the dump runs once. + string dump = MethodBody(platform, "private void OptimumRunParityDump()"); + int done = dump.IndexOf("if (optimumParityDumpDone)", StringComparison.Ordinal); + int inWorld = dump.IndexOf("!runningScreen.runningGame.BlocksReceivedAndLoaded", StringComparison.Ordinal); + int count = dump.IndexOf("optimumParityWorldFrames = worldFrame + 1;", StringComparison.Ordinal); + int frameCheck = dump.IndexOf("if (worldFrame != OptimumParityDump.Frame)", StringComparison.Ordinal); + int latch = dump.IndexOf("optimumParityDumpDone = true;", StringComparison.Ordinal); + Assert.True(done >= 0 && inWorld > done && count > inWorld && frameCheck > count && latch > frameCheck); + } + + [Fact] + public void GlReadbackUnbindsThePackBufferAndReadsTheSharedRepresentation() + { + string platform = ReadSourceOrPatched(PlatformPatch, PlatformSource); + string gl = MethodBody(platform, "private OptimumTextureReadback OptimumParityReadTextureGl(int textureId)"); + Assert.Contains("GL.BindBuffer((BufferTarget)35051, 0);", gl); + Assert.Contains("GL.BindBuffer((BufferTarget)35051, previousPackBuffer);", gl); + Assert.Contains("GL.BindTexture((TextureTarget)3553, previousTexture);", gl); + Assert.Contains("(GetTextureParameter)4099, out internalFormat", gl); + Assert.Contains("GL.GetTexImage((TextureTarget)3553, 0, (PixelFormat)6402, (PixelType)5126, depth);", gl); + Assert.Contains("GL.GetTexImage((TextureTarget)3553, 0, (PixelFormat)6408, (PixelType)5121, bytes);", gl); + Assert.Contains("GL.GetTexImage((TextureTarget)3553, 0, (PixelFormat)6408, (PixelType)5126, floats);", gl); + } + + [Fact] + public void ParityMembersAreCecilSafeAndShipped() + { + string platform = ReadSourceOrPatched(PlatformPatch, PlatformSource); + string[] methods = + { + "private void OptimumRunParityDump()", + "private string OptimumParitySlotName(int slot)", + "private int OptimumParityDumpAttachment(", + "private OptimumTextureReadback OptimumParityReadTextureGl(int textureId)", + }; + foreach (string signature in methods) + { + string body = MethodBody(platform, signature); + Assert.DoesNotContain("=>", body); + Assert.DoesNotContain("$\"", body); + Assert.DoesNotContain("delegate", body); + Assert.DoesNotContain("string.Format", body); + foreach (string line in body.Split('\n')) + { + // More than four concatenated operands can lower to span helpers. + Assert.True(Count(line, "\" + ") + Count(line, " + \"") <= 3, "long concatenation: " + line.Trim()); + } + } + Assert.DoesNotContain("optimumParityWorldFrames =", platform.Substring(0, platform.IndexOf("private void OptimumRunParityDump()", StringComparison.Ordinal))); + + string patcher = Read("Optimum.Patcher/Program.cs"); + foreach (string member in new[] + { + "optimumParityWorldFrames", "optimumParityDumpDone", "OptimumRunParityDump", + "OptimumParitySlotName", "OptimumParityDumpAttachment", "OptimumParityReadTextureGl", + }) + { + Assert.Contains("\"" + member + "\",", patcher); + } + Assert.Contains("new(\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"window_RenderFrame\", 1)", patcher); + } + + [Fact] + public void SsimSelfTestPasses() + { + string script = PatchReader.FindRepositoryFile("scripts/dev/ssim.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/ssim.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), "ssim.py --self-test did not finish"); + Assert.True(process.ExitCode == 0, "ssim.py --self-test exited " + process.ExitCode + ": " + stdout + stderr); + Assert.Contains("ssim.py self-test: ok", stdout); + } + + [Fact] + public void CaptureScriptConfirmsRendererRestoresConfigAndNeverPatternKills() + { + string script = Read("scripts/dev/parity-capture.sh"); + foreach (string needle in new[] + { + "--renderer", "--world", "--frame", "--out", + "export OPTIMUM_PARITY_DUMP=\"$OUT_DIR\"", "export OPTIMUM_PARITY_FRAME=\"$FRAME\"", + "scripts/dev/run-client.sh", "scripts/dev/kill-client.sh", + "trap cleanup EXIT", "set_renderer \"$SAVED_RENDERER\"", + "wait_for \"[Client Chat] Welcome\"", "wait_for \"[Optimum] parity dump:\"", + "[Optimum] Vulkan renderer", "[Optimum] OpenGL renderer:", + }) + { + Assert.Contains(needle, script); + } + Assert.True(script.IndexOf("wait_for \"[Client Chat] Welcome\"", StringComparison.Ordinal) + < script.IndexOf("wait_for \"[Optimum] parity dump:\"", StringComparison.Ordinal)); + Assert.DoesNotContain("pkill", script); + Assert.DoesNotContain("pgrep", script); + Assert.DoesNotContain("xdotool", script); // no chat commands + Assert.DoesNotContain("sleep \"$", script); // polls, never a blind sleep on a configured delay + + // Errors stop the script (the EXIT trap still restores the config), the restore + // waits for the client process to be gone (kill-client.sh does not wait), and a + // client that exited is re-checked at once, not after a delay. + Assert.Contains("\nset -euo pipefail\n", script); + string cleanup = script.Substring(script.IndexOf("cleanup() {", StringComparison.Ordinal)); + cleanup = cleanup.Substring(0, cleanup.IndexOf("\n}\n", StringComparison.Ordinal)); + int waitExit = cleanup.IndexOf("wait_for_exit ", StringComparison.Ordinal); + int restore = cleanup.IndexOf("set_renderer \"$SAVED_RENDERER\"", StringComparison.Ordinal); + Assert.True(waitExit >= 0 && restore > waitExit, "the config restore must wait for the client to exit"); + Assert.True(script.IndexOf("wait_for_exit() {", StringComparison.Ordinal) < script.IndexOf("cleanup() {", StringComparison.Ordinal)); + Assert.Contains("grep -m1 -F \"[Optimum] parity dump:\" \"$LOG\" || true", script); + string waitFor = script.Substring(script.IndexOf("wait_for() {", StringComparison.Ordinal)); + waitFor = waitFor.Substring(0, waitFor.IndexOf("\n}\n", StringComparison.Ordinal)); + Assert.DoesNotContain("sleep 1", waitFor); + } + + [Fact] + public void AcceptanceDocumentsHaveTheirSections() + { + string allowlist = Read("docs/parity-allowlist.md"); + Assert.Contains("| attachment | reason | max accepted deviation |", allowlist); + Assert.DoesNotMatch(new Regex(@"^\|\s*[^a|\-\s]", RegexOptions.Multiline), allowlist); // table starts empty + + string acceptance = Read("docs/vulkan-acceptance.md"); + string[] sections = + { + "## 0. Preconditions", "## 1. Renderer confirmation", "## 2. Acceptance rows", + "## 3. Methods", "## 4. Evidence rules", "## 5. Decision record", "## 6. Vendor matrix", + }; + int last = -1; + foreach (string section in sections) + { + int index = acceptance.IndexOf(section, StringComparison.Ordinal); + Assert.True(index > last, "missing or out of order: " + section); + last = index; + } + foreach (string needle in new[] + { + "### Phase 0 exit", "GL-vs-GL noise floor", "Pacing baseline, OpenGL", "Pacing baseline, Vulkan", + "### Milestone 1", "scripts/dev/pacing-gate.sh", "scripts/dev/ssim.py", "sync,best", + "luma-diff", "Screenshot pairs are\n never evidence", "Intel Arc 140V", + "`ScopesOpened == PassCount`", "SSIM >= min(0.98", + }) + { + Assert.Contains(needle, acceptance); + } + } + + // ------------------------------------------------------------------ helpers + + private static SortedSet AssignedSlots(string body, Dictionary constants) + { + var slots = new SortedSet(); + foreach (Match match in Regex.Matches(body, @"list\[([^\]]+)\]\s*=[^=]")) + { + string index = match.Groups[1].Value.Trim(); + if (int.TryParse(index, out int literal)) slots.Add(literal); + else if (constants.TryGetValue(index, out int constant)) slots.Add(constant); + else if (index == "(int)enumFrameBuffer") + { + Match array = Regex.Match(body, @"EnumFrameBuffer\[\]\s+\w+\s*=\s*new\s+EnumFrameBuffer\[\d+\]\s*\{([^}]*)\}"); + Assert.True(array.Success, "the SSAO blur slot array moved"); + foreach (Match name in Regex.Matches(array.Groups[1].Value, @"EnumFrameBuffer\.(\w+)")) + { + slots.Add(EnumFrameBufferValues[name.Groups[1].Value]); + } + } + else Assert.Fail("unrecognised framebuffer slot index: " + index); + } + Assert.NotEmpty(slots); + return slots; + } + + private static Dictionary Constants(string platform) + { + var constants = new Dictionary(); + foreach (Match match in Regex.Matches(platform, @"const int (\w+) = (\d+);")) + { + constants[match.Groups[1].Value] = int.Parse(match.Groups[2].Value); + } + return constants; + } + + private static Dictionary EnumValues() + { + string? path = TryFind("VintagestoryApi/Client/Render/EnumFrameBuffer.cs"); + if (path != null) + { + string source = File.ReadAllText(path); + foreach (KeyValuePair entry in EnumFrameBufferValues) + { + Assert.Matches(new Regex(@"\b" + entry.Key + @"\s*=\s*" + entry.Value + @"\b"), source); + } + } + return EnumFrameBufferValues; + } + + private static string MethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "method not found: " + signature); + int open = source.IndexOf('{', start); + 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 ReadApi() + { + string? generated = TryFind("sources/VintagestoryApi/Client/optimum-render-device.cs"); + return File.ReadAllText(generated ?? PatchReader.FindRepositoryFile("VintagestoryApi/Client/optimum-render-device.cs")); + } + + private static int Count(string text, string needle) + { + int count = 0; + for (int index = text.IndexOf(needle, StringComparison.Ordinal); index >= 0; + index = text.IndexOf(needle, index + needle.Length, StringComparison.Ordinal)) + { + count++; + } + return count; + } + + private static string ReadSourceOrPatched(string patchPath, string sourcePath) + { + // Whole method bodies are parsed here, so the materialised source (the + // tree extract-patches.sh generates the patch from) comes first; a patch + // only carries its hunks, which cut SetupDefaultFrameBuffers short. + string? resolvedSource = TryFind(sourcePath); + if (resolvedSource != null) return File.ReadAllText(resolvedSource); + return PatchReader.ReadPatchedContent(PatchReader.FindRepositoryFile(patchPath)); + } + + 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/Optimum.Tests/patch-method-scopes-tests.cs b/Optimum.Tests/patch-method-scopes-tests.cs new file mode 100644 index 00000000..afbd465e --- /dev/null +++ b/Optimum.Tests/patch-method-scopes-tests.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The per-method attribution that ModPatcherManifestConsistencyTests and +/// TaaRuntimeDonorCoverageTests rest on. Both used to compare whole files, so a +/// patch that changed one method of a type vouched for every other method of it. +/// These fixtures pin the discrimination itself: the same marker, in the same +/// file, in the wrong method, must not read as covered. +/// +public sealed class PatchMethodScopesTests : IDisposable +{ + private readonly string dir = Directory.CreateTempSubdirectory("optimum-scopes").FullName; + + private const string Source = """ +namespace Demo; + +public class Widget +{ + private int count; + + public Widget(int start) + { + count = start; + OptimumStandardMotion.Apply("ctor"); + } + + public void Draw(float dt) + { + if (count > 0) + { + OptimumMotionWrite.Begin(); + } + Console.WriteLine("drawing the widget now"); + } + + public void Tick(float dt) + { + count++; + Console.WriteLine("ticking the widget now"); + } +} +"""; + + private const string Patch = """ +diff --git a/Demo/Widget.cs b/Demo/Widget.cs +--- a/Demo/Widget.cs ++++ b/Demo/Widget.cs +@@ -14,6 +14,10 @@ public class Widget + public void Draw(float dt) + { ++ if (count > 0) ++ { ++ OptimumMotionWrite.Begin(); ++ } + Console.WriteLine("drawing the widget now"); + } +"""; + + [Fact] + public void AddedLinesAreAttributedToTheMethodThatEnclosesThem() + { + var touched = PatchMethodScopes.MethodsTouched(Write("widget.patch", Patch), Source); + + Assert.Contains("Draw", touched); + // The whole-file check these tests replaced would have accepted Tick as + // covered too, because the patch and the file share a type. + Assert.DoesNotContain("Tick", touched); + Assert.DoesNotContain("Widget", touched); + } + + [Fact] + public void MarkersAreKeyedByTheirEnclosingMethod() + { + var byMethod = PatchMethodScopes.MarkersByMethod( + Write("markers.patch", Patch), Source, new[] { "OptimumMotionWrite.Begin", "OptimumStandardMotion.Apply" }); + + Assert.Equal(new[] { "Draw" }, byMethod.Keys); + Assert.Equal(new[] { "OptimumMotionWrite.Begin" }, byMethod["Draw"]); + } + + /// + /// The failure this guards: the same marker present in the file, but in the + /// constructor instead of the draw call, so Cecil's per-method transplant + /// still ships a vanilla Draw. + /// + [Fact] + public void AMarkerInTheWrongMethodIsNotCoverageForTheRightOne() + { + // Same patch, but a tree whose only OptimumMotionWrite.Begin sits in the + // constructor: the file-wide marker set is identical, the per-method one + // is not. + string misplaced = """ +namespace Demo; + +public class Widget +{ + private int count; + + public Widget(int start) + { + count = start; + OptimumMotionWrite.Begin(); + } + + public void Draw(float dt) + { + Console.WriteLine("drawing the widget now"); + } +} +"""; + + var byMethod = PatchMethodScopes.MarkersByMethod( + Write("misplaced.patch", Patch), misplaced, new[] { "OptimumMotionWrite.Begin" }); + + Assert.Equal(new[] { "Widget" }, byMethod.Keys); + Assert.DoesNotContain("Draw", byMethod.Keys); + } + + [Fact] + public void BracesInsideCommentsAndStringsDoNotShiftTheNesting() + { + string source = """ +namespace Demo; + +public class Widget +{ + public void Draw(float dt) + { + // a stray } brace in a comment + Console.WriteLine("a stray } brace in a string"); + OptimumMotionWrite.Begin(); + } + + public void Tick(float dt) + { + Console.WriteLine("ticking the widget now"); + } +} +"""; + var scopes = PatchMethodScopes.Parse(source); + int at = source.IndexOf("OptimumMotionWrite.Begin", StringComparison.Ordinal); + + Assert.Equal("Draw", PatchMethodScopes.MethodAt(scopes, at)); + } + + private string Write(string name, string content) + { + string path = Path.Combine(dir, name); + File.WriteAllText(path, content); + return path; + } + + public void Dispose() + { + try { Directory.Delete(dir, recursive: true); } catch (IOException) { } + } +} diff --git a/Optimum.Tests/platform-client-program-coverage-tests.cs b/Optimum.Tests/platform-client-program-coverage-tests.cs new file mode 100644 index 00000000..653939c5 --- /dev/null +++ b/Optimum.Tests/platform-client-program-coverage-tests.cs @@ -0,0 +1,322 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 1: ClientProgram.Start constructs the platform the +/// backend hands it (VulkanClientPlatform : ClientPlatformWindows), initializes graphics +/// through an injected virtual once the window is open, and on failure swaps in a plain +/// ClientPlatformWindows. Each of these fails silently when wrong: a lambda breaks the Cecil +/// transplant, a fallback that forgets ScreenManager.Platform renders through the dead +/// Vulkan platform, a member missing from Program.cs ships nothing. +/// +public class PlatformClientProgramCoverageTests +{ + private static string StartRegion() + { + string program = ReadLib("Vintagestory.Client/ClientProgram.cs"); + int start = program.IndexOf("private unsafe void Start(ClientProgramArgs args, string[] rawArgs)", StringComparison.Ordinal); + int end = program.IndexOf("private GameWindowNative AttemptToOpenWindow(", StringComparison.Ordinal); + Assert.True(start >= 0 && end > start, "Start must precede AttemptToOpenWindow"); + return program.Substring(start, end - start); + } + + [Fact] + public void TheStartRegionHasNoLambda() + { + string region = StartRegion(); + + Assert.Contains("private void ConfigureClientPlatform(ClientPlatformWindows clientPlatformWindows)", region); + Assert.Contains("private void WireClientPlatform(ClientPlatformWindows clientPlatformWindows)", region); + Assert.Contains("private void OptimumStartSinglePlayerServer(StartServerArgs serverargs)", region); + Assert.DoesNotContain("delegate", region); + foreach (string line in region.Split('\n')) + { + // Vanilla's window-mode switch expression arms (`3 => 3,`) are not lambdas. + if (Regex.IsMatch(line, @"^\s*(\d+|_)\s*=>\s*\d+,?\s*$")) continue; + Assert.DoesNotContain("=>", line); + } + } + + [Fact] + public void StartCreatesTheBackendPlatformAndFallsBackToTheBase() + { + string region = StartRegion(); + + int probe = region.IndexOf("OptimumRenderBootstrap.ShouldTryVulkan(", StringComparison.Ordinal); + int create = region.IndexOf("OptimumRenderBootstrap.CreatePlatform(logger) as ClientPlatformWindows;", StringComparison.Ordinal); + int fallback = region.IndexOf("clientPlatformWindows = new ClientPlatformWindows(logger);", StringComparison.Ordinal); + int configure = region.IndexOf("ConfigureClientPlatform(clientPlatformWindows);", StringComparison.Ordinal); + int screenManager = region.IndexOf("screenManager = new ScreenManager(clientPlatformWindows);", StringComparison.Ordinal); + int window = region.IndexOf("AttemptToOpenWindow(gameWindowSettings, val2, num3, num4, 3);", StringComparison.Ordinal); + int initialize = region.IndexOf("clientPlatformWindows.InitializeGraphics(", StringComparison.Ordinal); + + Assert.True(probe >= 0 && create > probe, "the probe runs before the platform is created"); + Assert.True(fallback > create, "a null platform falls back to the base constructor"); + Assert.True(configure > fallback && screenManager > configure); + Assert.True(window > screenManager && initialize > window, "graphics initialize after the window opens"); + Assert.DoesNotContain("OptimumRenderBootstrap.Install", region); + Assert.DoesNotContain("OptimumRenderBootstrap.Shutdown", region); + } + + [Fact] + public void TheFallbackReassignsScreenManagerPlatform() + { + string region = StartRegion(); + + int reason = region.IndexOf("\"[Optimum] Vulkan unavailable, reopening for OpenGL: \" + optimumInstallReason", StringComparison.Ordinal); + Assert.True(reason >= 0, "the fallback log line keeps its shape"); + int reopen = region.IndexOf("AttemptToOpenWindow(gameWindowSettings, val2, num3, num4, 3);", reason, StringComparison.Ordinal); + int rebuild = region.IndexOf("clientPlatformWindows = new ClientPlatformWindows(logger);", reason, StringComparison.Ordinal); + int configure = region.IndexOf("ConfigureClientPlatform(clientPlatformWindows);", reason, StringComparison.Ordinal); + int assign = region.IndexOf("ScreenManager.Platform = clientPlatformWindows;", reason, StringComparison.Ordinal); + int start = region.IndexOf("screenManager.Start(args, rawArgs);", StringComparison.Ordinal); + + Assert.True(reopen > reason, "the window is reopened for OpenGL"); + Assert.True(rebuild > reopen && configure > rebuild && assign > configure, + "the fallback builds, wires and publishes a base platform"); + Assert.True(start > assign, "the swap happens before screenManager.Start"); + } + + [Fact] + public void ShutdownGraphicsRunsInTheFinallyBeforeTheWindowIsDisposed() + { + string region = StartRegion(); + + int run = region.IndexOf("((GameWindow)gameWindowNative).Run();", StringComparison.Ordinal); + Assert.True(run >= 0); + int finallyBlock = region.IndexOf("finally", run, StringComparison.Ordinal); + int shutdown = region.IndexOf("clientPlatformWindows.ShutdownGraphics();", run, StringComparison.Ordinal); + int dispose = region.IndexOf("((NativeWindow)gameWindowNative).Dispose();", run, StringComparison.Ordinal); + + Assert.True(finallyBlock > run && shutdown > finallyBlock && dispose > shutdown); + } + + /// + /// Step-1 review finding: a throw after InitializeGraphics succeeded but before the Run + /// block (window setup, screenManager.Start, the platform's Start) skipped the Run + /// finally, so the device stayed alive and the Vulkan crash marker survived a clean + /// failure. Everything between the bring-up and Run now sits in a try whose catch shuts + /// graphics down and rethrows. + /// + [Fact] + public void AThrowBetweenGraphicsBringUpAndRunStillShutsGraphicsDown() + { + string region = StartRegion(); + + int initialize = region.IndexOf("clientPlatformWindows.InitializeGraphics(", StringComparison.Ordinal); + int run = region.IndexOf("((GameWindow)gameWindowNative).Run();", StringComparison.Ordinal); + Assert.True(initialize >= 0 && run > initialize); + string between = region.Substring(initialize, run - initialize); + + var guard = Regex.Match(between, @"try\s*\{\s*if \(\(int\)val == 0 && !RuntimeEnv\.IsWaylandSession\)"); + Assert.True(guard.Success, "the window setup after the bring-up is not inside a try"); + var handler = Regex.Match(between, @"catch \(Exception\)\s*\{\s*clientPlatformWindows\.ShutdownGraphics\(\);\s*throw;\s*\}"); + Assert.True(handler.Success, "no catch shuts graphics down and rethrows before Run"); + + int screenStart = between.IndexOf("screenManager.Start(args, rawArgs);", StringComparison.Ordinal); + int platformStart = between.IndexOf("clientPlatformWindows.Start();", StringComparison.Ordinal); + int audio = between.IndexOf("clientPlatformWindows.StartAudio();", StringComparison.Ordinal); + Assert.True(audio > guard.Index && screenStart > audio && platformStart > screenStart + && handler.Index > platformStart, "screenManager.Start and the platform Start are guarded"); + } + + /// + /// Step-1 review finding: the wiring vanilla does after LogAndTestHardwareInfosStage1, + /// the install and message-box checks and the signal handlers had moved before all of + /// them on every path. Only the renderer probe and the platform construction may move + /// earlier; the rest keeps vanilla's order (_ref/.../ClientProgram.cs). + /// + [Fact] + public void StartKeepsVanillasOrderAroundThePlatformWiring() + { + string region = StartRegion(); + + string[] anchors = + { + "OptimumRenderBootstrap.ShouldTryVulkan(", + "clientPlatformWindows = new ClientPlatformWindows(logger);", + "ConfigureClientPlatform(clientPlatformWindows);", + "clientPlatformWindows.LogAndTestHardwareInfosStage1();", + "screenManager = new ScreenManager(clientPlatformWindows);", + "if (!Directory.Exists(GamePaths.AssetsPath))", + "if (!CleanInstallCheck.IsCleanInstall())", + "Signals[1] = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnExit);", + "WireClientPlatform(clientPlatformWindows);", + "WindowState val = ", + "AttemptToOpenWindow(gameWindowSettings, val2, num3, num4, 3);", + }; + int previous = -1; + foreach (string anchor in anchors) + { + int at = region.IndexOf(anchor, previous + 1, StringComparison.Ordinal); + Assert.True(at > previous, "out of vanilla order or missing: " + anchor); + previous = at; + } + + string configure = MethodBody(region, "private void ConfigureClientPlatform(ClientPlatformWindows clientPlatformWindows)"); + Assert.Contains("clientPlatformWindows.ShaderUniforms.SepiaLevel = ClientSettings.SepiaLevel;", configure); + Assert.Contains("CrashReporter.SetLogger((Logger)clientPlatformWindows.Logger);", configure); + Assert.DoesNotContain("SetServerExitInterface", configure); + Assert.DoesNotContain("platform = clientPlatformWindows;", configure); + + string wire = MethodBody(region, "private void WireClientPlatform(ClientPlatformWindows clientPlatformWindows)"); + int exit = wire.IndexOf("clientPlatformWindows.SetServerExitInterface(clientPlatformWindows.ServerExitState);", StringComparison.Ordinal); + int reporter = wire.IndexOf("clientPlatformWindows.crashreporter = crashreporter;", StringComparison.Ordinal); + int assign = wire.IndexOf("platform = clientPlatformWindows;", StringComparison.Ordinal); + int server = wire.IndexOf("clientPlatformWindows.OnStartSinglePlayerServer = OptimumStartSinglePlayerServer;", StringComparison.Ordinal); + Assert.True(exit >= 0 && reporter > exit && assign > reporter && server > assign); + + // The OpenGL fallback applies both halves to the platform it builds. + int reason = region.IndexOf("\"[Optimum] Vulkan unavailable, reopening for OpenGL: \" + optimumInstallReason", StringComparison.Ordinal); + int fallbackConfigure = region.IndexOf("ConfigureClientPlatform(clientPlatformWindows);", reason, StringComparison.Ordinal); + int fallbackWire = region.IndexOf("WireClientPlatform(clientPlatformWindows);", reason, StringComparison.Ordinal); + int fallbackAssign = region.IndexOf("ScreenManager.Platform = clientPlatformWindows;", reason, StringComparison.Ordinal); + Assert.True(reason >= 0 && fallbackConfigure > reason && fallbackWire > fallbackConfigure && fallbackAssign > fallbackWire); + + // Where the vanilla reference is available, its two wiring blocks sit on the + // same sides of the same anchors. + string? vanilla = TryRead("_ref/VintagestoryLib/Vintagestory.Client/ClientProgram.cs"); + if (vanilla != null) + { + int stage1 = vanilla.IndexOf("clientPlatformWindows.LogAndTestHardwareInfosStage1();", StringComparison.Ordinal); + int logger = vanilla.IndexOf("CrashReporter.SetLogger((Logger)clientPlatformWindows.Logger);", StringComparison.Ordinal); + int signals = vanilla.IndexOf("Signals[1] = PosixSignalRegistration.Create(PosixSignal.SIGINT, OnExit);", StringComparison.Ordinal); + int vanillaExit = vanilla.IndexOf("clientPlatformWindows.SetServerExitInterface(clientPlatformWindows.ServerExitState);", StringComparison.Ordinal); + Assert.True(logger >= 0 && stage1 > logger && signals > stage1 && vanillaExit > signals); + } + } + + [Fact] + public void TheRendererLogLinesKeepTheirShape() + { + string region = StartRegion(); + + Assert.Contains("Console.WriteLine(\"[Optimum] Vulkan renderer: \" +", region); + Assert.Contains("Console.WriteLine(\"[Optimum] OpenGL renderer: \" + optimumRendererReason);", region); + Assert.Contains("Console.WriteLine(\"[Optimum] OpenGL renderer: selected by config\");", region); + Assert.Contains("Console.WriteLine(\"[Optimum] Vulkan unavailable, reopening for OpenGL: \" + optimumInstallReason);", region); + } + + [Fact] + public void CreatePlatformReturnsObjectAndInstallIsGone() + { + string bootstrap = Read("sources/VintagestoryApi/Client/optimum-render-bootstrap.cs"); + + Assert.Contains("public static object CreatePlatform(object logger)", bootstrap); + Assert.Contains("\"Optimum.Render.Vulkan.Platform.VulkanClientPlatform\"", bootstrap); + Assert.DoesNotContain("public static bool Install(", bootstrap); + } + + [Fact] + public void TheAbstractPlatformDeclaresTheGraphicsVirtuals() + { + string platform = ReadLib("Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"); + + Assert.Contains("public virtual bool InitializeGraphics(IntPtr windowHandle, int width, int height, out string reason)", platform); + Assert.Contains("public virtual void ShutdownGraphics()", platform); + } + + [Fact] + public void ThePatcherListsEveryNewMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + string abstractMembers = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()"); + Assert.Contains("\"InitializeGraphics\",", abstractMembers); + Assert.Contains("\"ShutdownGraphics\",", abstractMembers); + + string programMembers = Block(patcher, "[\"Vintagestory.Client.ClientProgram\"] = new()"); + Assert.Contains("\"ConfigureClientPlatform\",", programMembers); + Assert.Contains("\"WireClientPlatform\",", programMembers); + Assert.Contains("\"OptimumStartSinglePlayerServer\",", programMembers); + + Assert.Contains("new(\"Vintagestory.Client.ClientProgram\", \"Start\", 2)", patcher); + } + + [Fact] + public void VulkanClientPlatformDerivesFromClientPlatformWindows() + { + string platform = Read("Optimum.Render.Vulkan/Platform/VulkanClientPlatform.cs"); + + Assert.Contains("namespace Optimum.Render.Vulkan.Platform;", 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); + // 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); + } + + [Fact] + public void TheRendererCompilesAgainstTheDonorWithoutShippingIt() + { + string renderer = Regex.Replace(Read("Optimum.Render.Vulkan/Optimum.Render.Vulkan.csproj"), @"\s+", " "); + // Compile-only: no copy, and no NuGet or transitive project flow that + // CopyLocalLockFileAssemblies would copy into the shared deploy output. + Assert.Contains(" false all ", renderer); + Assert.Contains("true", renderer); + + string makefile = Read("Makefile"); + Assert.DoesNotContain("$(MOD_OUT)/VintagestoryLib", makefile); + foreach (string script in new[] { "scripts/package-linux.sh", "scripts/package-macos.sh" }) + Assert.DoesNotContain("$MOD_OUT/VintagestoryLib", Read(script)); + } + + private static string MethodBody(string source, string signature) + { + int start = source.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + signature); + int open = source.IndexOf('{', start); + 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? TryRead(string relativePath) + { + try + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + catch (FileNotFoundException) + { + return null; + } + } + + private static string Block(string source, string header) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf("},", start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } + + private static string ReadLib(string relativePath) + { + string build = "build/VintagestoryLib/" + relativePath; + try + { + return File.ReadAllText(PatchReader.FindRepositoryFile(build)); + } + catch (FileNotFoundException) + { + return PatchReader.ReadPatchedContent(PatchReader.FindRepositoryFile( + "patches/VintagestoryLib/" + relativePath + ".patch")); + } + } + + private static string Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} 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..ad2e1c40 --- /dev/null +++ b/Optimum.Tests/platform-device-branch-move-coverage-tests.cs @@ -0,0 +1,150 @@ +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) + || selfCheck.Contains("new(true, \"get_" + 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 new file mode 100644 index 00000000..278dec0e --- /dev/null +++ b/Optimum.Tests/platform-program-ubo-virtuals-coverage-tests.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 3: shader program, uniform and UBO calls are +/// ClientPlatformAbstract virtuals. ShaderProgramBase and UBO are back to the vanilla +/// shape with ScreenManager.Platform calls where the GL lines were; ClientPlatformWindows +/// holds the device branch and the GL lines as overrides. A direct device or GL call left +/// in either class would bypass whichever platform the client installed. +/// +public class PlatformProgramUboVirtualsCoverageTests +{ + private const string AbstractPath = "Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"; + private const string WindowsPath = "Vintagestory.Client.NoObf/ClientPlatformWindows.cs"; + private const string ProgramPath = "Vintagestory.Client.NoObf/ShaderProgramBase.cs"; + private const string UboPath = "Vintagestory.Client.NoObf/UBO.cs"; + + /// 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", "", "GL.UseProgram(programId);"), + ("DisposeShaderProgram", "ShaderProgramBase program", "optimumDevice.DeleteProgram(program.ProgramId);", "GL.DeleteProgram(program.ProgramId);"), + ("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);"), + ("SetUniform", "int programId, int location, float x, float y, float z", "optimumDevice.SetUniform(programId, location, x, y, z);", "GL.Uniform3(location, x, y, z);"), + ("SetUniform", "int programId, int location, float x, float y, float z, float w", "optimumDevice.SetUniform(programId, location, x, y, z, w);", "GL.Uniform4(location, x, y, z, w);"), + ("SetUniform", "int programId, int location, int x, int y, int z", "optimumDevice.SetUniform(programId, location, x, y, z);", "GL.Uniform3(location, x, y, z);"), + ("SetUniformArray1", "int programId, int location, int count, float[] values", "optimumDevice.SetUniformArray1(programId, location, count, values);", "GL.Uniform1(location, count, values);"), + ("SetUniformArray2", "int programId, int location, int count, float[] values", "optimumDevice.SetUniformArray2(programId, location, count, values);", "GL.Uniform2(location, count, values);"), + ("SetUniformArray3", "int programId, int location, int count, float[] values", "optimumDevice.SetUniformArray3(programId, location, count, values);", "GL.Uniform3(location, count, values);"), + ("SetUniformArray4", "int programId, int location, int count, float[] values", "optimumDevice.SetUniformArray4(programId, location, count, values);", "GL.Uniform4(location, count, values);"), + ("SetUniformMatrix", "int programId, int location, float[] matrix", "optimumDevice.SetUniformMatrix(programId, location, matrix);", "GL.UniformMatrix4(location, 1, false, matrix);"), + ("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", "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);"), + ("DeleteUBO", "UBO ubo", "optimumDevice.DeleteUniformBuffer(ubo.Handle);", "GL.DeleteBuffers(1, ref ubo.Handle);"), + }; + + [Theory] + [InlineData(ProgramPath)] + [InlineData(UboPath)] + public void TheClassHasNoDeviceBranchAndNoDirectGlCall(string path) + { + string code = StripComments(ReadLib(path)); + + Assert.DoesNotContain("OptimumRender.Device", code); + Assert.DoesNotContain("IOptimumGraphicsDevice", code); + Assert.DoesNotContain("optimumDevice", code); + Assert.DoesNotMatch(new Regex(@"\bGL\."), code); + } + + [Fact] + public void ShaderProgramBaseRoutesEveryMovedOperationThroughThePlatform() + { + string program = StripComments(ReadLib(ProgramPath)); + + var expected = new (string Method, string Call)[] + { + ("public void Uniform(string uniformName, float value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], value);"), + ("public void Uniform(string uniformName, int count, float[] value)", "ScreenManager.Platform.SetUniformArray1(ProgramId, uniformLocations[uniformName], count, value);"), + ("public void Uniform(string uniformName, int value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], value);"), + ("public void Uniform(string uniformName, Vec2f value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], value.X, value.Y);"), + ("public void Uniform(string uniformName, Vec2i value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], (float)value.X, (float)value.Y);"), + ("public void Uniform(string uniformName, float valueX, float valueY)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], valueX, valueY);"), + ("public void Uniform(string uniformName, Vec3f value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], value.X, value.Y, value.Z);"), + ("public void Uniform(string uniformName, float valueX, float valueY, float valueZ)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], valueX, valueY, valueZ);"), + ("public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], valueX, valueY, valueZ, valueW);"), + ("public void Uniform(string uniformName, Vec3i value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], value.X, value.Y, value.Z);"), + ("public void Uniforms2(string uniformName, int count, float[] values)", "ScreenManager.Platform.SetUniformArray2(ProgramId, uniformLocations[uniformName], count, values);"), + ("public void Uniforms3(string uniformName, int count, float[] values)", "ScreenManager.Platform.SetUniformArray3(ProgramId, uniformLocations[uniformName], count, values);"), + ("public void Uniform(string uniformName, Vec4f value)", "ScreenManager.Platform.SetUniform(ProgramId, uniformLocations[uniformName], value.X, value.Y, value.Z, value.W);"), + ("public void Uniforms4(string uniformName, int count, float[] values)", "ScreenManager.Platform.SetUniformArray4(ProgramId, uniformLocations[uniformName], count, values);"), + ("public void UniformMatrix(string uniformName, float[] matrix)", "ScreenManager.Platform.SetUniformMatrix(ProgramId, uniformLocations[uniformName], matrix);"), + ("public void UniformMatrix(string uniformName, ref Matrix4 matrix)", "ScreenManager.Platform.SetUniformMatrix(ProgramId, uniformLocations[uniformName], ref matrix);"), + ("public void BindTexture2D(string samplerName, int textureId, int textureNumber)", "ScreenManager.Platform.BindProgramTexture2D(this, samplerName, textureId, textureNumber);"), + ("public void BindTextureCube(string samplerName, int textureId, int textureNumber)", "ScreenManager.Platform.BindProgramTextureCube(this, samplerName, textureId, textureNumber);"), + ("public void UniformMatrices4x3(string uniformName, int count, float[] matrix)", "ScreenManager.Platform.SetUniformMatrices4x3(ProgramId, uniformLocations[uniformName], count, matrix);"), + ("public void UniformMatrices(string uniformName, int count, float[] matrix)", "ScreenManager.Platform.SetUniformMatrices(ProgramId, uniformLocations[uniformName], count, matrix);"), + ("public void Use()", "ScreenManager.Platform.UseShaderProgram(ProgramId);"), + ("public void Stop()", "ScreenManager.Platform.UseShaderProgram(0);"), + ("public void Stop()", "ScreenManager.Platform.BindSampler(i, 0);"), + ("public void Dispose()", "ScreenManager.Platform.DisposeShaderProgram(this);"), + }; + + foreach ((string method, string call) in expected) + { + Assert.True(Body(program, method).Contains(call, StringComparison.Ordinal), method + " does not call " + call); + } + + // The TAA hooks stay in the program, ahead of the platform call. + Assert.Contains("if (OptimumEntityMotion.Enabled) OptimumEntityMotion.NoteWarpUniform(uniformName, value);", program); + Assert.Contains("if (OptimumEntityMotion.Enabled && uniformName == \"modelMatrix\") OptimumEntityMotion.NoteModelMatrix(matrix);", program); + } + + [Fact] + public void UboRoutesEveryMovedOperationThroughThePlatform() + { + string ubo = StripComments(ReadLib(UboPath)); + + Assert.Contains("ScreenManager.Platform.BindUBO(this);", Body(ubo, "public override void Bind()")); + Assert.Contains("ScreenManager.Platform.UnbindUBO(this);", Body(ubo, "public override void Unbind()")); + Assert.Contains("ScreenManager.Platform.DeleteUBO(this);", Body(ubo, "public override void Dispose()")); + // Update(data) replaced the whole buffer on GL (glBufferData); the ranged ones write into it. + Assert.Contains("ScreenManager.Platform.UpdateUBO(this, (IntPtr)gCHandleProvider.Pointer, 0, base.Size, true);", + Body(ubo, "public override void Update(T data)")); + Assert.Contains("ScreenManager.Platform.UpdateUBO(this, (IntPtr)gCHandleProvider.Pointer, offset, size, false);", + Body(ubo, "public override void Update(T data, int offset, int size)")); + Assert.Contains("ScreenManager.Platform.UpdateUBO(this, (IntPtr)num, offset, size, false);", + Body(ubo, "public override void Update(object data, int offset, int size)")); + } + + [Fact] + public void TheAbstractPlatformDeclaresEveryOperationWithAnEmptyBody() + { + string platform = ReadLib(AbstractPath); + + foreach ((string name, string parameters, _, _) in Members) + { + string signature = "public virtual void " + name + "(" + parameters.Replace("ref Matrix4", "ref OpenTK.Mathematics.Matrix4") + ")"; + string inner = Regex.Replace(Body(platform, signature), @"\s+", " ").Trim(); + Assert.True(inner == "{ }", signature + " is not empty: " + inner); + } + } + + /// + /// 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 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"); + + 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)")); + // 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)")); + } + + [Fact] + public void ThePatcherInjectsTheVirtualsAndTheOverridesAndKeepsTheBodyTargets() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + string abstractMembers = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()"); + string windowsMembers = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformWindows\"] = new()"); + var names = new HashSet(); + foreach ((string name, _, _, _) in Members) names.Add(name); + foreach (string name in names) + { + Assert.Contains("\"" + name + "\",", abstractMembers); + Assert.Contains("\"" + name + "\",", windowsMembers); + } + + foreach (string target in new[] + { + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"Use\", 0)", + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"Stop\", 0)", + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"Dispose\", 0)", + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"BindTexture2D\", 3)", + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"BindTextureCube\", 3)", + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"UniformMatrices\", 3)", + "new(\"Vintagestory.Client.NoObf.ShaderProgramBase\", \"UniformMatrices4x3\", 3)", + "new(\"Vintagestory.Client.NoObf.UBO\", \"Bind\", 0)", + "new(\"Vintagestory.Client.NoObf.UBO\", \"Unbind\", 0)", + "new(\"Vintagestory.Client.NoObf.UBO\", \"Dispose\", 0)", + }) + { + Assert.Contains(target, patcher); + } + } + + /// + /// Outside the platform calls and the TAA hooks, ShaderProgramBase is vanilla again: + /// every line the patch adds is one of those, a comment or blank. + /// + [Fact] + public void ShaderProgramBaseDiffersFromVanillaOnlyByPlatformCallsAndTaaHooks() + { + string patch = Read("patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderProgramBase.cs.patch"); + var offenders = new List(); + foreach (string raw in patch.Split('\n')) + { + if (!raw.StartsWith("+", StringComparison.Ordinal) || raw.StartsWith("+++", StringComparison.Ordinal)) continue; + string line = raw.Substring(1).Trim(); + if (line.Length == 0 || line.StartsWith("//", StringComparison.Ordinal)) continue; + if (line.StartsWith("ScreenManager.Platform.", StringComparison.Ordinal)) continue; + if (line.StartsWith("if (OptimumEntityMotion.Enabled", StringComparison.Ordinal)) continue; + offenders.Add(line); + } + Assert.True(offenders.Count == 0, "non-routing additions:\n" + string.Join("\n", offenders)); + } + + 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) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf("},", start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } + + 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 Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} 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..30cced59 --- /dev/null +++ b/Optimum.Tests/platform-seam-deletion-coverage-tests.cs @@ -0,0 +1,272 @@ +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(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)); + + 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); + } + + /// + /// 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() + { + 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);", "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);" }; + 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);", "StateDrawBuffers(transparent.FboId, 0x3F);" }; + 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.ReadFramebufferColor(CurrentTargetId, 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/platform-substitution-coverage-tests.cs b/Optimum.Tests/platform-substitution-coverage-tests.cs new file mode 100644 index 00000000..45e3b263 --- /dev/null +++ b/Optimum.Tests/platform-substitution-coverage-tests.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 0: VulkanClientPlatform subclasses ClientPlatformWindows, so the +/// patcher must unseal the class and virtualize the members it overrides, and the donor the +/// renderer compiles against must declare the same shape. A missing entry on either side +/// only shows up at runtime (TypeLoadException, or an override that is silently bypassed). +/// +public class PlatformSubstitutionCoverageTests +{ + private const string PlatformType = "\"Vintagestory.Client.NoObf.ClientPlatformWindows\""; + + private static readonly (string Name, int ParamCount, string Declaration)[] VirtualizedMembers = + { + ("SetupDefaultFrameBuffers", 0, "public virtual List SetupDefaultFrameBuffers()"), + ("DisposeFrameBuffers", 1, "public virtual void DisposeFrameBuffers(List buffers)"), + ("RenderFullscreenTriangle", 1, "public virtual void RenderFullscreenTriangle(MeshRef modelRef)"), + ("GetGraphicsCardRenderer", 0, "public virtual string GetGraphicsCardRenderer()"), + }; + + [Fact] + public void PatcherUnsealsThePlatformClass() + { + string unseal = ListBody(Read("Optimum.Patcher/Program.cs"), "var typesToUnseal = new List"); + + Assert.Contains(PlatformType + ",", unseal); + } + + [Fact] + public void PatcherVirtualizesEveryOverriddenPlatformMember() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + string virtualize = ListBody(patcher, "var methodsToVirtualize = new List"); + + foreach (var member in VirtualizedMembers) + Assert.Contains($"new({PlatformType}, \"{member.Name}\", {member.ParamCount})", virtualize); + + Assert.Contains("typesToUnseal: typesToUnseal", patcher); + Assert.Contains("methodsToVirtualize: methodsToVirtualize", patcher); + } + + [Fact] + public void PatcherVerifiesVirtualDispatchBeforeWritingTheOutput() + { + string ilPatcher = Read("Optimum.Patcher/ILPatcher.cs"); + + int transplant = ilPatcher.IndexOf("TransplantBody(vanillaMethod, compiledMethod", StringComparison.Ordinal); + int hooks = ilPatcher.IndexOf("// Phase 3: IL hooks", StringComparison.Ordinal); + int virtualize = ilPatcher.IndexOf("PlatformSubstitution.VirtualizeMethods(", StringComparison.Ordinal); + int verify = ilPatcher.IndexOf("PlatformSubstitution.VerifyVirtualDispatch(", StringComparison.Ordinal); + int write = ilPatcher.IndexOf("AssemblyWriter.Write(vanillaAsm", StringComparison.Ordinal); + + Assert.True(transplant >= 0 && hooks > transplant); + Assert.True(virtualize > hooks, "flags must be applied after every transplant and hook"); + Assert.True(verify > virtualize && write > verify, "the dispatch verifier must run before the write"); + } + + [Fact] + public void DonorDeclaresTheSubclassableShape() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs", optional: true) + ?? PatchReader.ReadPatchedContent(PatchReader.FindRepositoryFile( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch")); + + Assert.Contains("public class ClientPlatformWindows : ClientPlatformAbstract", platform); + Assert.DoesNotContain("sealed class ClientPlatformWindows", platform); + foreach (var member in VirtualizedMembers) + Assert.Contains(member.Declaration, platform); + } + + private static string ListBody(string source, string declaration) + { + int start = source.IndexOf(declaration, StringComparison.Ordinal); + Assert.True(start >= 0, $"missing declaration: {declaration}"); + int end = source.IndexOf("};", start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } + + private static string? Read(string relativePath, bool optional = false) + { + try + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } + catch (FileNotFoundException) when (optional) + { + return null; + } + } + + private static string Read(string relativePath) => Read(relativePath, optional: false)!; +} diff --git a/Optimum.Tests/platform-substitution-patcher-tests.cs b/Optimum.Tests/platform-substitution-patcher-tests.cs new file mode 100644 index 00000000..fe33726d --- /dev/null +++ b/Optimum.Tests/platform-substitution-patcher-tests.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Optimum.Patcher; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Synthetic-module tests for the patcher's platform substitution step (typesToUnseal, +/// methodsToVirtualize and the call-versus-callvirt dispatch verifier). Same fixture pattern +/// as member-injector-tests.cs: in-memory Cecil modules named VintagestoryLib. +/// +public sealed class PlatformSubstitutionPatcherTests +{ + private const string PlatformName = "Vintagestory.Client.NoObf.ClientPlatformWindows"; + private const string CallerName = "Vintagestory.Client.ClientProgram"; + + private static readonly List Virtualize = new() + { + new(PlatformName, "SetupDefaultFrameBuffers", 0), + }; + + [Fact] + public void UnsealClearsOnlySealed() + { + using AssemblyDefinition assembly = CreateModule(); + TypeDefinition platform = AddPlatform(assembly.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + TypeAttributes before = platform.Attributes; + Assert.True(platform.IsSealed); + + int unsealed = PlatformSubstitution.UnsealTypes(assembly.MainModule, new[] { PlatformName }); + + Assert.Equal(1, unsealed); + Assert.False(platform.IsSealed); + Assert.Equal(before & ~TypeAttributes.Sealed, platform.Attributes); + } + + [Fact] + public void VirtualizeSetsVirtualNewSlotHideBySigAndKeepsVisibility() + { + using AssemblyDefinition assembly = CreateModule(); + TypeDefinition platform = AddPlatform(assembly.MainModule, MethodAttributes.Public); + MethodDefinition protectedMethod = AddVoidMethod(platform, "RenderFullscreenTriangle", MethodAttributes.Family); + + PlatformSubstitution.VirtualizeMethods(assembly.MainModule, new List + { + new(PlatformName, "SetupDefaultFrameBuffers", 0), + new(PlatformName, "RenderFullscreenTriangle", 0), + }); + + MethodDefinition setup = Find(platform, "SetupDefaultFrameBuffers"); + Assert.Equal( + MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.NewSlot | MethodAttributes.HideBySig, + setup.Attributes); + Assert.True(setup.IsPublic); + Assert.Equal( + MethodAttributes.Family | MethodAttributes.Virtual | MethodAttributes.NewSlot | MethodAttributes.HideBySig, + protectedMethod.Attributes); + } + + [Fact] + public void PrivateEntryFailsThePatch() + { + using AssemblyDefinition assembly = CreateModule(); + AddPlatform(assembly.MainModule, MethodAttributes.Private | MethodAttributes.HideBySig); + + InvalidOperationException error = Assert.Throws( + () => PlatformSubstitution.VirtualizeMethods(assembly.MainModule, Virtualize)); + + Assert.Contains("private", error.Message); + Assert.Contains("SetupDefaultFrameBuffers", error.Message); + } + + [Fact] + public void CallToVirtualizedMethodFailsTheVerifierNamingTheCaller() + { + using AssemblyDefinition assembly = CreateModule(); + TypeDefinition platform = AddPlatform(assembly.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + AddCaller(assembly.MainModule, platform, OpCodes.Call); + Apply(assembly.MainModule); + + List errors = Verify(assembly.MainModule, out int virtualSites); + + string error = Assert.Single(errors); + Assert.Contains(CallerName + "::Start", error); + Assert.Contains("SetupDefaultFrameBuffers", error); + Assert.Contains(" call ", error); + Assert.Equal(0, virtualSites); + } + + [Fact] + public void CallvirtToVirtualizedMethodPassesTheVerifier() + { + using AssemblyDefinition assembly = CreateModule(); + TypeDefinition platform = AddPlatform(assembly.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + AddCaller(assembly.MainModule, platform, OpCodes.Callvirt); + Apply(assembly.MainModule); + + List errors = Verify(assembly.MainModule, out int virtualSites); + + Assert.Empty(errors); + Assert.Equal(1, virtualSites); + } + + [Fact] + public void LdftnInANestedTypeFailsTheVerifier() + { + using AssemblyDefinition assembly = CreateModule(); + TypeDefinition platform = AddPlatform(assembly.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + TypeDefinition caller = AddCaller(assembly.MainModule, platform, OpCodes.Callvirt); + TypeDefinition closure = new("", "<>c", TypeAttributes.NestedPrivate | TypeAttributes.Class, assembly.MainModule.TypeSystem.Object); + caller.NestedTypes.Add(closure); + MethodDefinition lambda = new("b__0", MethodAttributes.Assembly | MethodAttributes.HideBySig, assembly.MainModule.TypeSystem.Void); + closure.Methods.Add(lambda); + ILProcessor il = lambda.Body.GetILProcessor(); + il.Append(il.Create(OpCodes.Ldftn, Find(platform, "SetupDefaultFrameBuffers"))); + il.Append(il.Create(OpCodes.Pop)); + il.Append(il.Create(OpCodes.Ret)); + Apply(assembly.MainModule); + + List errors = Verify(assembly.MainModule, out _); + + string error = Assert.Single(errors); + Assert.Contains(CallerName + "/<>c::b__0", error); + Assert.Contains("ldftn", error); + } + + [Fact] + public void BaseCallFromASubclassPassesTheVerifier() + { + using AssemblyDefinition assembly = CreateModule(); + TypeDefinition platform = AddPlatform(assembly.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + TypeDefinition derived = new("Fixture", "DerivedPlatform", TypeAttributes.Public | TypeAttributes.Class, platform); + assembly.MainModule.Types.Add(derived); + MethodDefinition overrideMethod = new( + "SetupDefaultFrameBuffers", + MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig, + assembly.MainModule.TypeSystem.Void); + derived.Methods.Add(overrideMethod); + ILProcessor il = overrideMethod.Body.GetILProcessor(); + il.Append(il.Create(OpCodes.Ldarg_0)); + il.Append(il.Create(OpCodes.Call, Find(platform, "SetupDefaultFrameBuffers"))); + il.Append(il.Create(OpCodes.Ret)); + Apply(assembly.MainModule); + + Assert.Empty(Verify(assembly.MainModule, out _)); + } + + [Fact] + public void VerifierFailsWhenTheFlagsWereNotApplied() + { + using AssemblyDefinition assembly = CreateModule(); + AddPlatform(assembly.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + + List errors = Verify(assembly.MainModule, out _); + + Assert.Contains(errors, error => error.Contains("still carries TypeAttributes.Sealed")); + Assert.Contains(errors, error => error.Contains("is not virtual")); + } + + [Fact] + public void PatchWithInjectionKeepsFlagsOnTransplantedMethodsAndRefusesAStrayCall() + { + string directory = Path.Combine(Path.GetTempPath(), "optimum-platform-substitution-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(directory, "vanilla")); + Directory.CreateDirectory(Path.Combine(directory, "compiled")); + try + { + string compiledPath = Path.Combine(directory, "compiled", "VintagestoryLib.dll"); + using (AssemblyDefinition compiled = CreateModule()) + { + TypeDefinition platform = AddPlatform( + compiled.MainModule, + MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.NewSlot | MethodAttributes.HideBySig, + sealedType: false); + AddCaller(compiled.MainModule, platform, OpCodes.Callvirt); + compiled.Write(compiledPath); + } + + var targets = new List + { + new(PlatformName, "SetupDefaultFrameBuffers", 0), + new(CallerName, "Start", 0), + }; + + // 1. Both the virtualized method and its caller are transplanted: flags survive, output written. + string vanillaPath = WriteVanilla(directory, "vanilla-ok.dll", OpCodes.Call); + string outputPath = Path.Combine(directory, "patched-ok.dll"); + int result = ILPatcher.PatchWithInjection( + vanillaPath, compiledPath, outputPath, new List(), new Dictionary>(), targets, + typesToUnseal: new List { PlatformName }, methodsToVirtualize: Virtualize); + + Assert.True(result > 0); + using (AssemblyDefinition patched = AssemblyDefinition.ReadAssembly(outputPath)) + { + TypeDefinition platform = patched.MainModule.GetType(PlatformName); + Assert.False(platform.IsSealed); + MethodDefinition setup = Find(platform, "SetupDefaultFrameBuffers"); + Assert.True(setup.IsVirtual && setup.IsNewSlot && setup.IsHideBySig && setup.IsPublic); + Assert.Contains( + Find(patched.MainModule.GetType(CallerName), "Start").Body.Instructions, + instruction => instruction.OpCode == OpCodes.Callvirt); + } + + // 2. The caller is not transplanted and keeps its vanilla `call`: the patch is refused. + string strayPath = WriteVanilla(directory, "vanilla-stray.dll", OpCodes.Call); + string strayOutput = Path.Combine(directory, "patched-stray.dll"); + int refused = ILPatcher.PatchWithInjection( + strayPath, compiledPath, strayOutput, new List(), new Dictionary>(), + new List { targets[0] }, + typesToUnseal: new List { PlatformName }, methodsToVirtualize: Virtualize); + + Assert.Equal(-1, refused); + Assert.False(File.Exists(strayOutput)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + private static string WriteVanilla(string directory, string fileName, OpCode callOpCode) + { + string path = Path.Combine(directory, "vanilla", fileName); + using AssemblyDefinition vanilla = CreateModule(); + TypeDefinition platform = AddPlatform(vanilla.MainModule, MethodAttributes.Public | MethodAttributes.HideBySig); + AddCaller(vanilla.MainModule, platform, callOpCode); + vanilla.Write(path); + return path; + } + + private static void Apply(ModuleDefinition module) + { + PlatformSubstitution.UnsealTypes(module, new[] { PlatformName }); + PlatformSubstitution.VirtualizeMethods(module, Virtualize); + } + + private static List Verify(ModuleDefinition module, out int virtualSites) => + PlatformSubstitution.VerifyVirtualDispatch(module, new[] { PlatformName }, Virtualize, out virtualSites); + + private static AssemblyDefinition CreateModule() => + AssemblyDefinition.CreateAssembly( + new AssemblyNameDefinition("VintagestoryLib", new Version(1, 0)), + "VintagestoryLib", + ModuleKind.Dll); + + private static TypeDefinition AddPlatform(ModuleDefinition module, MethodAttributes setupAttributes, bool sealedType = true) + { + TypeAttributes attributes = TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.BeforeFieldInit; + if (sealedType) attributes |= TypeAttributes.Sealed; + TypeDefinition platform = new("Vintagestory.Client.NoObf", "ClientPlatformWindows", attributes, module.TypeSystem.Object); + module.Types.Add(platform); + AddVoidMethod(platform, "SetupDefaultFrameBuffers", setupAttributes); + return platform; + } + + private static MethodDefinition AddVoidMethod(TypeDefinition type, string name, MethodAttributes attributes) + { + MethodDefinition method = new(name, attributes, type.Module.TypeSystem.Void); + type.Methods.Add(method); + method.Body.GetILProcessor().Append(Instruction.Create(OpCodes.Ret)); + return method; + } + + private static TypeDefinition AddCaller(ModuleDefinition module, TypeDefinition platform, OpCode callOpCode) + { + TypeDefinition caller = new("Vintagestory.Client", "ClientProgram", TypeAttributes.Public | TypeAttributes.Class, module.TypeSystem.Object); + module.Types.Add(caller); + MethodDefinition start = new("Start", MethodAttributes.Public | MethodAttributes.HideBySig, module.TypeSystem.Void); + start.Parameters.Clear(); + caller.Methods.Add(start); + start.Body.Variables.Add(new VariableDefinition(platform)); + ILProcessor il = start.Body.GetILProcessor(); + il.Append(il.Create(OpCodes.Ldloc_0)); + il.Append(il.Create(callOpCode, Find(platform, "SetupDefaultFrameBuffers"))); + il.Append(il.Create(OpCodes.Ret)); + return caller; + } + + private static MethodDefinition Find(TypeDefinition type, string name) + { + foreach (MethodDefinition method in type.Methods) + { + if (method.Name == name) return method; + } + throw new InvalidOperationException($"{type.FullName}::{name} not found"); + } +} diff --git a/Optimum.Tests/platform-taa-virtuals-coverage-tests.cs b/Optimum.Tests/platform-taa-virtuals-coverage-tests.cs new file mode 100644 index 00000000..66832aad --- /dev/null +++ b/Optimum.Tests/platform-taa-virtuals-coverage-tests.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Vulkan-native plan, Phase 1A step 2: the TAA/FSR members are virtual on +/// ClientPlatformAbstract, ClientPlatformWindows overrides them with the OpenGL bodies, +/// and no lib code casts the platform to ClientPlatformWindows any more. A cast left +/// behind fails silently on the Vulkan platform only in the sense that it still works +/// (VulkanClientPlatform derives from ClientPlatformWindows) while bypassing the design: +/// the next step's overrides would be reached by some callers and not others. +/// +public class PlatformTaaVirtualsCoverageTests +{ + private const string AbstractPath = "Vintagestory.Client.NoObf/ClientPlatformAbstract.cs"; + private const string WindowsPath = "Vintagestory.Client.NoObf/ClientPlatformWindows.cs"; + + /// The virtual, its neutral body on the abstract, and the override signature. + private static readonly (string Virtual, string NeutralBody, string Override)[] Members = + { + ("public virtual int MotionAttachmentIndex", "return -1;", "public override int MotionAttachmentIndex"), + ("public virtual bool OptimumMotionWriteActive", "return false;", "public override bool OptimumMotionWriteActive"), + ("public virtual bool TaaTargetsReady", "return false;", "public override bool TaaTargetsReady"), + ("public virtual bool TaaResolvedThisFrame", "return false;", "public override bool TaaResolvedThisFrame"), + ("public virtual FrameBufferRef TaaHistory(int parity)", "return null;", "public override FrameBufferRef TaaHistory(int parity)"), + ("public virtual bool BeginMotionWrite()", "return false;", "public override bool BeginMotionWrite()"), + ("public virtual void EndMotionWrite()", "", "public override void EndMotionWrite()"), + ("public virtual bool BeginMotionOnlyWrite()", "return false;", "public override bool BeginMotionOnlyWrite()"), + ("public virtual void EndMotionOnlyWrite()", "", "public override void EndMotionOnlyWrite()"), + ("public virtual bool RenderOptimumSkyMotion()", "return false;", "public override bool RenderOptimumSkyMotion()"), + ("public virtual bool RenderOptimumTaaResolve()", "return false;", "public override bool RenderOptimumTaaResolve()"), + ("public virtual int RenderOptimumTaaSharpen(int resolvedScene)", "return resolvedScene;", "public override int RenderOptimumTaaSharpen(int resolvedScene)"), + ("public virtual bool OptimumFsrBlitActive()", "return false;", "public override bool OptimumFsrBlitActive()"), + ("public virtual void DisableOptimumTaa(string reason)", "", "public override void DisableOptimumTaa(string reason)"), + }; + + private static readonly Regex PlatformCast = new(@"\b(as|is)\s+ClientPlatformWindows\b|\(\s*ClientPlatformWindows\s*\)\s*[\w(]"); + + // The one conversion the plan keeps: the reflective factory returns object. + private const string AllowedCreation = "OptimumRenderBootstrap.CreatePlatform(logger) as ClientPlatformWindows;"; + + [Fact] + public void NoLibCodeCastsThePlatformToClientPlatformWindows() + { + var offenders = new List(); + int allowed = 0; + foreach ((string file, string line) in LibSourceLines()) + { + if (!PlatformCast.IsMatch(line)) continue; + string trimmed = line.Trim(); + if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("///", StringComparison.Ordinal)) continue; + if (file.EndsWith("ClientProgram.cs", StringComparison.Ordinal) || file.EndsWith("ClientProgram.cs.patch", StringComparison.Ordinal)) + { + if (trimmed.EndsWith(AllowedCreation, StringComparison.Ordinal)) + { + allowed++; + continue; + } + } + offenders.Add(file + ": " + trimmed); + } + + Assert.True(offenders.Count == 0, "casts to ClientPlatformWindows remain:\n" + string.Join("\n", offenders)); + Assert.Equal(1, allowed); + } + + [Fact] + public void TheSevenFormerCastSitesCallThroughTheAbstractPlatform() + { + string chunk = ReadLib("Vintagestory.Client.NoObf/ChunkRenderer.cs"); + Assert.Equal(3, Regex.Matches(chunk, Regex.Escape("ClientPlatformAbstract optimumPlatform = platform;")).Count); + + foreach (string system in new[] { "SystemRenderEntities.cs", "SystemRenderDecals.cs", "SystemRenderParticles.cs" }) + { + string source = ReadLib("Vintagestory.Client.NoObf/" + system); + Assert.Single(Regex.Matches(source, Regex.Escape("ClientPlatformAbstract optimumPlatform = game.Platform;"))); + } + + string clientMain = ReadLib("Vintagestory.Client.NoObf/ClientMain.cs"); + Assert.Contains("Platform.RenderOptimumSkyMotion();", clientMain); + Assert.DoesNotContain("optimumSkyMotionPlatform", clientMain); + } + + [Fact] + public void TheAbstractPlatformDeclaresEveryTaaMemberWithANeutralBody() + { + string platform = ReadLib(AbstractPath); + + foreach ((string signature, string neutral, _) in Members) + { + string body = Body(platform, signature); + string inner = Regex.Replace(body, @"\s+", " ").Trim(); + string expected = signature.Contains('(') + ? (neutral.Length == 0 ? "{ }" : "{ " + neutral + " }") + : "{ get { " + neutral + " } }"; + Assert.True(inner == expected, signature + " is not neutral: " + inner); + } + } + + [Fact] + public void ClientPlatformWindowsOverridesEveryTaaMember() + { + string platform = ReadLib(WindowsPath); + + foreach ((_, _, string signature) in Members) + { + Assert.Single(Regex.Matches(platform, Regex.Escape(signature) + @"(?![\w])")); + } + + // The state members read private fields, and nothing hides them with a + // non-virtual declaration of the same name. + Assert.Contains("private int optimumMotionAttachmentIndex = -1;", platform); + Assert.Contains("private bool optimumMotionWriteActive;", platform); + Assert.Contains("private bool optimumTaaTargetsReady;", platform); + Assert.Contains("private bool optimumTaaResolvedThisFrame;", platform); + Assert.DoesNotContain("private bool TaaTargetsReady;", platform); + Assert.DoesNotContain("{ get; private set; }", Body(platform, "public override int MotionAttachmentIndex")); + Assert.DoesNotContain("internal bool RenderOptimumSkyMotion()", platform); + } + + [Fact] + public void ThePatcherInjectsTheVirtualsAndTheOverrideFields() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + + string abstractMembers = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformAbstract\"] = new()"); + foreach (string name in new[] + { + "MotionAttachmentIndex", "OptimumMotionWriteActive", "TaaTargetsReady", "TaaResolvedThisFrame", + "TaaHistory", "BeginMotionWrite", "EndMotionWrite", "BeginMotionOnlyWrite", "EndMotionOnlyWrite", + "RenderOptimumSkyMotion", "RenderOptimumTaaResolve", "RenderOptimumTaaSharpen", "OptimumFsrBlitActive", + "DisableOptimumTaa", + }) + { + Assert.Contains("\"" + name + "\",", abstractMembers); + } + + string windowsMembers = Block(patcher, "[\"Vintagestory.Client.NoObf.ClientPlatformWindows\"] = new()"); + foreach (string field in new[] + { + "optimumMotionAttachmentIndex", "optimumTaaTargetsReady", "optimumTaaResolvedThisFrame", "optimumMotionWriteActive", + }) + { + Assert.Contains("\"" + field + "\",", windowsMembers); + } + } + + private static IEnumerable<(string File, string Line)> LibSourceLines() + { + string root = RepositoryRoot(); + string lib = Path.Combine(root, "build", "VintagestoryLib"); + if (Directory.Exists(lib)) + { + foreach (string file in Directory.EnumerateFiles(lib, "*.cs", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(lib, file); + if (relative.StartsWith("bin" + Path.DirectorySeparatorChar, StringComparison.Ordinal) + || relative.StartsWith("obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + continue; + foreach (string line in File.ReadLines(file)) + yield return (relative, line); + } + yield break; + } + + // Un-bootstrapped checkout: every Optimum line in the lib is an added patch line. + foreach (string file in Directory.EnumerateFiles(Path.Combine(root, "patches", "VintagestoryLib"), "*.patch", SearchOption.AllDirectories)) + { + foreach (string line in File.ReadLines(file)) + { + if (line.StartsWith("+", StringComparison.Ordinal) && !line.StartsWith("+++", StringComparison.Ordinal)) + yield return (Path.GetFileName(file), line.Substring(1)); + } + } + } + + private static string RepositoryRoot() + { + string patcher = PatchReader.FindRepositoryFile("Optimum.Patcher/Program.cs"); + return Path.GetDirectoryName(Path.GetDirectoryName(patcher)!)!; + } + + 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) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "missing: " + header); + int end = source.IndexOf("},", start, StringComparison.Ordinal); + Assert.True(end > start); + return source.Substring(start, end - start); + } + + 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 Read(string relativePath) => + File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); +} 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/Optimum.Tests/scene-ssao-coverage-tests.cs b/Optimum.Tests/scene-ssao-coverage-tests.cs new file mode 100644 index 00000000..9fd306fe --- /dev/null +++ b/Optimum.Tests/scene-ssao-coverage-tests.cs @@ -0,0 +1,82 @@ +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"); + // 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 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 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(aoStep >= 0 && aoStep < resolve, "the AO is composed before the resolve"); + Assert.Equal(1, Count(post, "ssao.Use();")); + Assert.Contains("if (OptimumTaaRequested && TaaTargetsReady)", post); + + // 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")); + + 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 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] + 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/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/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs b/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs new file mode 100644 index 00000000..8e7e6e78 --- /dev/null +++ b/Optimum.Tests/ssao-temporal-dither-coverage-tests.cs @@ -0,0 +1,152 @@ +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() + { + // 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 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/Optimum.Tests/taa-acceptance-harness-coverage-tests.cs b/Optimum.Tests/taa-acceptance-harness-coverage-tests.cs new file mode 100644 index 00000000..09be7108 --- /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} stddev={6: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/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-entity-motion-coverage-tests.cs b/Optimum.Tests/taa-entity-motion-coverage-tests.cs new file mode 100644 index 00000000..b9ef07f5 --- /dev/null +++ b/Optimum.Tests/taa-entity-motion-coverage-tests.cs @@ -0,0 +1,445 @@ +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, 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 + // 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); + // Phase 1A step 3: the bind itself is ClientPlatformWindows.BindUBO (asserted below). + 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"); + + Assert.Contains("GL.BindBufferBase((BufferRangeTarget)35345, ubo.BindingPoint, ubo.Handle);", platform); + + // 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 + + [Fact] + public void TheFrameContractKeepsPerEntityHistoryKeyedOnTheAnimator() + { + string frame = Read("sources/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("sources/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. + // 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. + string frame = Read("sources/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/Optimum.Tests/taa-instanced-motion-coverage-tests.cs b/Optimum.Tests/taa-instanced-motion-coverage-tests.cs new file mode 100644 index 00000000..31b8a2b0 --- /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, 0.0, reactive, 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("sources/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..48331d67 --- /dev/null +++ b/Optimum.Tests/taa-instanced-motion-history-tests.cs @@ -0,0 +1,295 @@ +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; + 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); + } + + 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/Optimum.Tests/taa-liquid-motion-coverage-tests.cs b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs new file mode 100644 index 00000000..9f43d027 --- /dev/null +++ b/Optimum.Tests/taa-liquid-motion-coverage-tests.cs @@ -0,0 +1,487 @@ +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); + // ... 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. + 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 override bool BeginMotionOnlyWrite()", platform); + Assert.Contains("public override void EndMotionOnlyWrite()", 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("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 + // 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 override bool BeginMotionOnlyWrite()", 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); + 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 override 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); + } + + /// + /// 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 + /// 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 + + /// 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) + { + 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++) + { + 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); + 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/Optimum.Tests/taa-mover-motion-coverage-tests.cs b/Optimum.Tests/taa-mover-motion-coverage-tests.cs new file mode 100644 index 00000000..a4f9c2e2 --- /dev/null +++ b/Optimum.Tests/taa-mover-motion-coverage-tests.cs @@ -0,0 +1,416 @@ +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. A file-wide search for + // "finally" would pass on a renderer whose window is closed by a bare call + // while some unrelated method has the keyword, so check it per window. + Assert.Equal( + Count(renderer, "OptimumMotionWrite.Begin();"), + Count(renderer, "OptimumMotionWrite.End();")); + AssertEveryWindowClosesInAFinally(source, renderer); + } + + /// + /// For every OptimumMotionWrite.Begin();, the End(); that closes it + /// must sit in a finally that opens after that Begin. + /// + private static void AssertEveryWindowClosesInAFinally(string source, string renderer) + { + const string beginCall = "OptimumMotionWrite.Begin();"; + const string endCall = "OptimumMotionWrite.End();"; + + for (int begin = renderer.IndexOf(beginCall, StringComparison.Ordinal); begin >= 0; + begin = renderer.IndexOf(beginCall, begin + beginCall.Length, StringComparison.Ordinal)) + { + int end = renderer.IndexOf(endCall, begin, StringComparison.Ordinal); + Assert.True(end > begin, source + ": a motion window opens and is never closed"); + + int keyword = renderer.IndexOf("finally", begin, StringComparison.Ordinal); + Assert.True(keyword > begin && keyword < end, + source + ": the motion window opened at offset " + begin + + " is not closed inside a finally block"); + } + } + + /// + /// The resonator runs the same body again on AfterFinalComposition, where + /// Begin() refuses because the temporal window is closed. Rolling the transform + /// history there would overwrite the identity's current transform with a later, + /// time-driven ModelMat, so next frame's previous transform would be off by a + /// sub-frame delta. Apply must therefore sit inside the window. + /// + [Fact] + public void TheResonatorRollsItsHistoryOnlyInsideTheWindow() + { + string renderer = ReadRepositoryFile("VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs"); + + int begin = renderer.IndexOf("OptimumMotionWrite.Begin();", StringComparison.Ordinal); + int apply = renderer.IndexOf("OptimumStandardMotion.Apply(", StringComparison.Ordinal); + int end = renderer.IndexOf("OptimumMotionWrite.End();", StringComparison.Ordinal); + + Assert.True(begin >= 0 && apply > begin && apply < end, + "the resonator must roll its transform history inside the motion window"); + Assert.Contains("if (optimumMotionWrite)", 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/Optimum.Tests/taa-particle-motion-coverage-tests.cs b/Optimum.Tests/taa-particle-motion-coverage-tests.cs new file mode 100644 index 00000000..125cc868 --- /dev/null +++ b/Optimum.Tests/taa-particle-motion-coverage-tests.cs @@ -0,0 +1,458 @@ +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); + // ... 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 + // 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)); + } + + /// + /// 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] + 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. + // 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); + 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). + // 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("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("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"); + 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-pipeline-coverage-tests.cs b/Optimum.Tests/taa-pipeline-coverage-tests.cs new file mode 100644 index 00000000..477f90d9 --- /dev/null +++ b/Optimum.Tests/taa-pipeline-coverage-tests.cs @@ -0,0 +1,473 @@ +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); + // 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. + 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() + { + // 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 + // motion attachment - it is enabled per-pass by writers, not by + // default. + Assert.Contains("StateDrawBuffers(primary.FboId, (1 << primaryAttachments) - 1);", platform); + // Transparent (OIT) keeps its untouched six/three-output mask. + Assert.Contains("StateDrawBuffers(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 (VulkanClientPlatform.ClearFrameBufferPass since Phase 1A step 4). + string vulkan = VulkanPlatformSource.Read(); + 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("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")); + // 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(1, Count(platform, "if (MotionAttachmentIndex >= 0)")); + Assert.Equal(1, Count(vulkan, "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); + } + + [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. + // 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 = OptimumPostSceneTexture();", resolveCall, StringComparison.Ordinal); + int postGlowDecl = platform.IndexOf( + "int postGlowTexture = OptimumPostGlowTexture();", resolveCall, StringComparison.Ordinal); + Assert.True(postSceneDecl > resolveCall); + Assert.True(postGlowDecl > postSceneDecl); + + 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. + 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); + } + + [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. 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); + 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); + + 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("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]))", deviceBody); + Assert.Contains("if (deletedTextures.Add(buffers[i].ColorTextureIds[j]))", body); + // No unguarded delete is left behind on either path. + 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);")); + } + + [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. + // 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, "closestDepth"), Tolerance(debug, "sceneDepth")); + Assert.DoesNotContain("abs(motion.a - sceneDepth) < 1e-4", debug); + } + + /// + /// Once DisableOptimumTaa has run, no later frame-buffer rebuild may retry + /// the allocation that just failed. OptimumConfig.TaaRuntimeDisabled already + /// makes EffectiveTaa false, but the platform's own optimumTaaDisabled flag + /// is the authority for this platform instance, so both setup paths gate on + /// it as well - a belt-and-braces guard that costs one field read per + /// rebuild. + /// + [Fact] + public void BothFrameBufferSetupPathsHonourTheRuntimeTaaDisable() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // 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); + + // And the flag really is set by the failure path. + Assert.Contains("optimumTaaDisabled = true;", platform); + } + + /// 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; + 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; + } + } + + [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 override 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("public override 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); + // 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] + public void SkyPixelsReprojectAsDirections() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + // 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-runtime-donor-coverage-tests.cs b/Optimum.Tests/taa-runtime-donor-coverage-tests.cs new file mode 100644 index 00000000..9ca8eeb1 --- /dev/null +++ b/Optimum.Tests/taa-runtime-donor-coverage-tests.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The TAA motion writers live in two places at once. The fork trees +/// (VSEssentials/, VSSurvivalMod/) are what a from-source build compiles, and +/// `patches/<mod>/**` is their checked-in record. The installed-launcher path +/// never sees those: it decompiles the user's own mod assemblies, applies +/// `patches/runtime/<mod>/**` to that decompiled tree, compiles it, and lets +/// Cecil transplant the method bodies named in Optimum.Patcher/mod-patcher.cs. +/// A mover instrumented only in the fork therefore keeps its vanilla body for +/// every installed player: it draws with no motion vector and ghosts on the +/// camera fallback, silently, with every fork test still green. +/// +/// These tests pin the two sides together. They read only checked-in patch +/// files, never the git-ignored fork trees, so they run the same in a clean +/// clone as on a developer machine. +/// +public sealed class TaaRuntimeDonorCoverageTests +{ + /// + /// The calls that mark a class as a TAA motion writer. A fork patch that + /// adds any of them describes work the installed runtime needs too. + /// + private static readonly string[] MotionMarkers = + [ + "OptimumStandardMotion.Apply", + "OptimumMotionWrite.Begin", + "OptimumMotionWrite.End", + "OptimumInstanceMotion.CreateInstanceFloats", + "OptimumInstanceMotion.WriteInstance", + "OptimumInstanceMotion.NoteDevice", + "OptimumInstanceMotion.ApplyPassUniforms", + "OptimumInstanceMotion.InstanceFloats", + "OptimumConfig.EffectiveTaa", + ]; + + /// + /// Fork patch -> runtime donor patch, both repository-relative. The mapping + /// is spelled out rather than derived because the two trees are shaped + /// differently: the fork keeps its own folders and file names + /// (Entities/EntityBlockFalling.cs holds ModSystemRenderFallingBlocksFast; + /// AngledGearBlockRenderer.cs holds AngledGearsBlockRenderer) while ILSpy + /// lays the donor out by namespace and names each file after its type. + /// fails if a new + /// motion writer appears in a fork patch without an entry here. + /// + private static readonly Dictionary DonorByForkPatch = new() + { + ["patches/VSEssentials/Entities/EntityBlockFalling.cs.patch"] = + "patches/runtime/VSEssentials/Vintagestory/GameContent/ModSystemRenderFallingBlocksFast.cs.patch", + ["patches/VSEssentials/EntityRenderer/EntityItemRenderer.cs.patch"] = + "patches/runtime/VSEssentials/Vintagestory/GameContent/EntityItemRenderer.cs.patch", + ["patches/VSEssentials/EntityRenderer/EntityPlayerShapeRenderer.cs.patch"] = + "patches/runtime/VSEssentials/Vintagestory/GameContent/EntityPlayerShapeRenderer.cs.patch", + ["patches/VSEssentials/EntityRenderer/EntityShapeRenderer.cs.patch"] = + "patches/runtime/VSEssentials/Vintagestory/GameContent/EntityShapeRenderer.cs.patch", + ["patches/VSEssentials/EntityRenderer/ModSystemFpHands.cs.patch"] = + "patches/runtime/VSEssentials/Vintagestory/GameContent/ModSystemFpHands.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/BloomeryContentsRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/BloomeryContentsRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/FirepitContentsRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/FirepitContentsRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/ForgeContentsRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/ForgeContentsRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/FruitpressContentsRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/FruitpressContentsRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/HelveHammerRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/HelveHammerRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/PotInFirepitRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/PotInFirepitRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/QuernTopRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/QuernTopRenderer.cs.patch", + ["patches/VSSurvivalMod/BlockEntityRenderer/ResonatorRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/ResonatorRenderer.cs.patch", + ["patches/VSSurvivalMod/Lore/ResoArchives/EchoChamberRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/EchoChamberRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledCageGearRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/AngledCageGearRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/AngledGearBlockRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/AngledGearsBlockRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/ClutchBlockRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/ClutchBlockRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/CreativeRotorRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/CreativeRotorRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/GenericMechBlockRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/GenericMechBlockRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechBlockRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/MechBlockRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/MechNetworkRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/MechNetworkRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/PulverizerRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/PulverizerRenderer.cs.patch", + ["patches/VSSurvivalMod/Systems/MechanicalPower/Renderer/TransmissionBlockRenderer.cs.patch"] = + "patches/runtime/VSSurvivalMod/Vintagestory/GameContent/Mechanics/TransmissionBlockRenderer.cs.patch", + }; + + public static IEnumerable DonorPairs => + DonorByForkPatch.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .Select(pair => new object[] { pair.Key, pair.Value }); + + [Theory] + [MemberData(nameof(DonorPairs))] + public void RuntimeDonorCarriesTheSameMotionWritersAsTheFork(string forkPatch, string runtimePatch) + { + string forkPath = Path.Combine(RepoRoot(), forkPatch); + string runtimePath = Path.Combine(RepoRoot(), runtimePatch); + + Assert.True(File.Exists(forkPath), $"{forkPatch} is missing; refresh it with scripts/extract-patches.sh."); + Assert.True( + File.Exists(runtimePath), + $"{runtimePatch} is missing. The fork instruments this class for TAA but the installed-launcher " + + "path has no donor for it, so Cecil would transplant a vanilla body and the surface would ghost."); + + var forkMarkers = MarkersAdded(forkPath); + var runtimeMarkers = MarkersAdded(runtimePath); + + Assert.True( + forkMarkers.Count > 0, + $"{forkPatch} no longer adds any TAA motion call; drop its entry from DonorByForkPatch."); + + var missing = forkMarkers.Except(runtimeMarkers).OrderBy(m => m, StringComparer.Ordinal).ToList(); + Assert.True( + missing.Count == 0, + $"{runtimePatch} is behind {forkPatch}: the fork adds {string.Join(", ", missing)} but the runtime " + + "donor does not. Regenerate the donor patch against a pristine .build/runtime-donors decompile."); + + // File-wide sets are not enough: a donor that writes the same markers + // into some OTHER method of the same class passes that check while the + // transplanted body still draws without motion. Compare per method + // wherever the two trees are on disk (both git-ignored, so a clean clone + // keeps only the file-wide check above). + string? forkTree = PatchMethodScopes.FindPatchedTreeFile(RepoRoot(), forkPatch); + string? donorTree = PatchMethodScopes.FindPatchedTreeFile(RepoRoot(), runtimePatch); + if (forkTree is null || donorTree is null) + { + return; + } + + var forkByMethod = PatchMethodScopes.MarkersByMethod( + forkPath, File.ReadAllText(forkTree), MotionMarkers); + var donorByMethod = PatchMethodScopes.MarkersByMethod( + runtimePath, File.ReadAllText(donorTree), MotionMarkers); + + var misplaced = new List(); + foreach (var (method, markers) in forkByMethod.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + donorByMethod.TryGetValue(method, out var donorMarkers); + foreach (string marker in markers.OrderBy(m => m, StringComparer.Ordinal)) + { + if (donorMarkers is null || !donorMarkers.Contains(marker)) + { + misplaced.Add($"{method}: {marker}"); + } + } + } + + Assert.True( + misplaced.Count == 0, + $"{runtimePatch} carries the fork's motion markers, but not in the same methods as " + + $"{forkPatch}. Cecil transplants per method, so a marker in the wrong body still ships a " + + "vanilla one:\n " + string.Join("\n ", misplaced)); + } + + [Fact] + public void EveryInstrumentedForkPatchIsListedHere() + { + var instrumented = new List(); + foreach (string project in new[] { "VSEssentials", "VSSurvivalMod", "VSCreativeMod" }) + { + string dir = Path.Combine(RepoRoot(), "patches", project); + if (!Directory.Exists(dir)) + { + continue; + } + foreach (string file in Directory.EnumerateFiles(dir, "*.patch", SearchOption.AllDirectories)) + { + if (MarkersAdded(file).Count > 0) + { + instrumented.Add(Relative(file)); + } + } + } + + var unlisted = instrumented + .Where(path => !DonorByForkPatch.ContainsKey(path)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.True( + unlisted.Count == 0, + "These fork patches add TAA motion calls but have no runtime donor mapping in " + + "TaaRuntimeDonorCoverageTests.DonorByForkPatch, so the installed-launcher path would keep " + + "vanilla bodies for them:\n " + string.Join("\n ", unlisted)); + } + + [Fact] + public void EveryMappedForkPatchStillExists() + { + var gone = DonorByForkPatch.Keys + .Where(path => !File.Exists(Path.Combine(RepoRoot(), path))) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.True( + gone.Count == 0, + "DonorByForkPatch names fork patches that no longer exist:\n " + string.Join("\n ", gone)); + } + + /// + /// The motion calls a patch adds (+ lines only). Context lines are + /// excluded on purpose: a call that merely sits next to an unrelated hunk is + /// not this patch's work. + /// + private static HashSet MarkersAdded(string patchFile) + { + var found = new HashSet(StringComparer.Ordinal); + foreach (string line in File.ReadLines(patchFile)) + { + if (!line.StartsWith("+", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal)) + { + continue; + } + foreach (string marker in MotionMarkers) + { + if (line.Contains(marker, StringComparison.Ordinal)) + { + found.Add(marker); + } + } + } + return found; + } + + private static string RepoRoot() => + Path.GetDirectoryName(PatchReader.FindRepositoryFile("VERSION"))!; + + private static string Relative(string path) => + Path.GetRelativePath(RepoRoot(), path).Replace('\\', '/'); +} diff --git a/Optimum.Tests/taa-settings-coverage-tests.cs b/Optimum.Tests/taa-settings-coverage-tests.cs new file mode 100644 index 00000000..07b92fb6 --- /dev/null +++ b/Optimum.Tests/taa-settings-coverage-tests.cs @@ -0,0 +1,452 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Optimum.Tests; + +/// +/// P5 settings: the three TAA rows in the Optimum settings tab, the scanner +/// rules that veto TAA when a mod owns one of its shaders, and the packaging +/// that has to carry every one of those shaders into a release. +/// +public class TaaSettingsCoverageTests +{ + // ---- (a) settings rows ------------------------------------------------- + + [Fact] + public void TheOptimumTabHasATaaToggleAndBothTaaSliders() + { + string gui = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + + Assert.Contains("Lang.Get(\"optimum-taa\")", gui); + Assert.Contains("AddSwitch(onOptimumTaaChanged", gui); + Assert.Contains("\"optTaa\")", gui); + + Assert.Contains("Lang.Get(\"optimum-taasharpness\")", gui); + Assert.Contains("AddSlider(onOptimumTaaSharpnessChanged", gui); + Assert.Contains("\"optTaaSharpness\")", gui); + + Assert.Contains("Lang.Get(\"optimum-taamipbias\")", gui); + Assert.Contains("AddSlider(onOptimumTaaMipBiasChanged", gui); + Assert.Contains("\"optTaaMipBias\")", gui); + + // Every row in this tab carries a hover text; a row without one reads as + // an unexplained switch in a list of explained ones. + Assert.Contains("Lang.Get(\"optimum-taa-tooltip\")", gui); + Assert.Contains("Lang.Get(\"optimum-taasharpness-tooltip\")", gui); + Assert.Contains("Lang.Get(\"optimum-taamipbias-tooltip\")", gui); + } + + [Fact] + public void TheRowsAreBackedByTheCurrentConfigurationWhenTheTabOpens() + { + string gui = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + + // EffectiveTaa, not Taa: a launcher verdict or a runtime fallback has + // already turned TAA off, and the switch must show what is running. + Assert.Contains( + "composer.GetSwitch(\"optTaa\").SetValue(Vintagestory.API.Config.OptimumConfig.EffectiveTaa);", + gui); + + // The sliders are integers; the config fields are floats in 0..1 and + // -1..0, so the rows carry hundredths. + Assert.Contains("GetSlider(\"optTaaSharpness\").SetValues(", gui); + Assert.Contains("OptimumConfig.TaaSharpness * 100f", gui); + Assert.Contains(", 0, 100, 5, \"%\");", gui); + Assert.Contains("GetSlider(\"optTaaMipBias\").SetValues(", gui); + Assert.Contains("OptimumConfig.TaaMipBias * 100f", gui); + Assert.Contains(", -100, 0, 5, \"/100\");", gui); + } + + [Fact] + public void TheToggleRebuildsTargetsReloadsShadersAndDropsTheHistory() + { + string gui = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + + string handler = Between(gui, "private void onOptimumTaaChanged(bool on)", "\n\t}"); + + // A missing launcher scan must not veto TAA: IsFeatureExplicitlyDisabled, + // like OptimumConfig.EffectiveTaa, never IsShaderFeatureDisabled (which + // reports everything disabled when no scan exists). + Assert.Contains("IsFeatureExplicitlyDisabled(\"Taa\")", handler); + Assert.DoesNotContain("IsShaderFeatureDisabled(\"Taa\")", handler); + + Assert.Contains("OptimumConfig.Taa = on;", handler); + Assert.Contains("OptimumConfig.Save();", handler); + // The history targets and the motion attachment only exist while TAA is + // on, final.fsh compiles its FXAA branch against EffectiveTaa, and a + // history captured under the other configuration must never be + // reprojected into a frame that did not produce it. + Assert.Contains("ScreenManager.Platform.RebuildFrameBuffers();", handler); + Assert.Contains("handler.ReloadShaders();", handler); + Assert.Contains("OptimumTemporal.RequestReset(EnumTemporalResetReason.Toggle);", handler); + } + + /// + /// The GUI switch has already flipped by the time the handler runs, so the + /// "TAA is explicitly disabled" bail-out has to put it back. Without the + /// reset the row shows TAA on for the rest of the session while + /// EffectiveTaa stays false. + /// + [Fact] + public void TheToggleResetsTheSwitchWhenTaaIsExplicitlyDisabled() + { + string gui = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + + string handler = Between(gui, "private void onOptimumTaaChanged(bool on)", "\n\t}"); + + // The reset targets the same switch key the row is built with, and + // takes its value from EffectiveTaa - the only truth the rest of the + // chain reads. + Assert.Contains("AddSwitch(onOptimumTaaChanged", gui); + Assert.Contains("\"optTaa\")", gui); + Assert.Contains( + "composer?.GetSwitch(\"optTaa\")?.SetValue(Vintagestory.API.Config.OptimumConfig.EffectiveTaa);", + handler); + + // And it happens before the bail-out, not after it. + int reset = handler.IndexOf("GetSwitch(\"optTaa\")", StringComparison.Ordinal); + int giveUp = handler.IndexOf("return;", StringComparison.Ordinal); + Assert.True(reset >= 0, "the explicit-disable bail-out never resets the switch"); + Assert.True(reset < giveUp, "the switch has to be reset before the handler returns"); + } + + [Fact] + public void BothSlidersApplyLiveAndNeverRebuildOrResetAnything() + { + string gui = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs"); + + foreach ((string name, string field, string clamp) in new[] + { + ("onOptimumTaaSharpnessChanged", "TaaSharpness", "GameMath.Clamp(val / 100f, 0f, 1f)"), + ("onOptimumTaaMipBiasChanged", "TaaMipBias", "GameMath.Clamp(val / 100f, -1f, 0f)"), + }) + { + string handler = Between(gui, "private bool " + name + "(int val)", "\n\t}"); + Assert.Contains("OptimumConfig." + field + " = " + clamp + ";", handler); + Assert.Contains("OptimumConfig.Save();", handler); + // Sharpen strength and LOD bias are read where they are used, so a + // frame buffer rebuild, a shader reload or a temporal reset on every + // drag step would be a stutter with no effect on the image. + Assert.DoesNotContain("RebuildFrameBuffers", handler); + Assert.DoesNotContain("ReloadShaders", handler); + Assert.DoesNotContain("RequestReset", handler); + } + } + + [Fact] + public void TheRowsFitTheFixedMainMenuDialog() + { + string gui = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/GuiCompositeSettings.cs.patch", + "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 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 + 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 * 23 <= 740.0); + Assert.True(87.0 + 23.0 * 27 <= 740.0); + } + + [Fact] + public void EveryNewRowHasItsTranslationStrings() + { + string lang = Read("sources/lang/en.json"); + foreach (string key in new[] + { + "optimum-taa", "optimum-taa-tooltip", + "optimum-taasharpness", "optimum-taasharpness-tooltip", + "optimum-taamipbias", "optimum-taamipbias-tooltip", + }) + { + Assert.Contains("\"" + key + "\":", lang); + } + } + + [Fact] + public void TheHandlersAreListedForTheCecilTransplant() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"onOptimumTaaChanged\"", patcher); + Assert.Contains("\"onOptimumTaaSharpnessChanged\"", patcher); + Assert.Contains("\"onOptimumTaaMipBiasChanged\"", patcher); + } + + [Fact] + public void TheSettingsPersistThroughOptimumJson() + { + string config = Read("VintagestoryApi/Config/OptimumConfig.cs"); + + // Written, read back, clamped on load and carried by the snapshot clone. + Assert.Contains("(nameof(OptimumConfigData.Taa), Taa.ToString())", config); + Assert.Contains("(nameof(OptimumConfigData.TaaSharpness), TaaSharpness.ToString(\"F2\"))", config); + Assert.Contains("(nameof(OptimumConfigData.TaaMipBias), TaaMipBias.ToString(\"F2\"))", config); + Assert.Contains("Taa = data.Taa;", config); + Assert.Contains("TaaSharpness = Math.Clamp(data.TaaSharpness, 0f, 1f);", config); + Assert.Contains("TaaMipBias = Math.Clamp(data.TaaMipBias, -2f, 1f);", config); + Assert.Contains("public bool Taa { get; set; }", config); + Assert.Contains("public float TaaSharpness { get; set; }", config); + Assert.Contains("public float TaaMipBias { get; set; }", config); + } + + // ---- (b) scanner rules ------------------------------------------------- + + [Fact] + public void TheScannerVetoesTaaForEveryShaderStageTaaOwns() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + string decision = Between(scanner, "bool externalMotionShader =", "AddFeatureDecision(report, \"Taa\""); + + foreach (string shader in new[] + { + // The resolve, its debug views, the sky-motion pass and the sharpen. + "taa-resolve.vsh", "taa-resolve.fsh", + "taa-debug.vsh", "taa-debug.fsh", + "taa-skymotion.vsh", "taa-skymotion.fsh", + "taa-sharpen.vsh", "taa-sharpen.fsh", + // The liquid velocity pass and the FSR pair the sharpen shares its + // vertex stage and lobe maths with. + "chunkliquidmotion.vsh", "chunkliquidmotion.fsh", + "fsr-easu.vsh", "fsr-easu.fsh", + "fsr-rcas.vsh", "fsr-rcas.fsh", + // The motion-vector writers themselves. + "chunkopaque.vsh", "chunkopaque.fsh", + "chunktopsoil.vsh", "chunktopsoil.fsh", + "entityanimated.vsh", "entityanimated.fsh", + "standard.vsh", "standard.fsh", + "instanced.vsh", "instanced.fsh", + "chunkliquid.vsh", + "particlescube.vsh", "particlescube.fsh", + "transparentcompose.fsh", + "decals.vsh", "decals.fsh", + "vertexwarp.vsh", + }) + { + Assert.Contains("HasExternalShader(report, \"" + shader + "\")", decision); + } + + // A stage added after this test was written is covered by the prefix, + // and the whole shaderincludes directory by the include rule - one is a + // list that goes stale, the other two do not. + Assert.Contains("HasExternalShaderPrefix(report, \"taa-\")", decision); + Assert.Contains("HasExternalShaderInclude(report)", decision); + + // "Taa" stays out of ShaderFeatures: a scanner failure must not veto a + // renderer feature the user asked for, only an explicit verdict does. + Assert.DoesNotContain("\"Taa\",", Between(scanner, "ShaderFeatures", "];")); + } + + [Fact] + public void EveryTaaShaderTheRepositoryShipsHasAScannerRule() + { + // The real guard against the list going stale: enumerate what + // sources/shaders actually contains rather than trusting the names above. + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + string decision = Between(scanner, "bool externalMotionShader =", "AddFeatureDecision(report, \"Taa\""); + string shaderDir = Path.GetDirectoryName( + PatchReader.FindRepositoryFile("sources/shaders/taa-resolve.fsh"))!; + + var checkedShaders = new List(); + foreach (string path in Directory.EnumerateFiles(shaderDir)) + { + string name = Path.GetFileName(path); + if (!name.StartsWith("taa-", StringComparison.Ordinal) && + !name.StartsWith("fsr-", StringComparison.Ordinal) && + !name.StartsWith("chunkliquidmotion", StringComparison.Ordinal)) + { + continue; + } + + checkedShaders.Add(name); + Assert.True( + decision.Contains("HasExternalShader(report, \"" + name + "\")", StringComparison.Ordinal), + "no scanner rule disables Taa when a mod ships " + name); + } + + Assert.True(checkedShaders.Count >= 10, "the shader enumeration found nothing to check"); + } + + // ---- (c) packaging ----------------------------------------------------- + + [Fact] + public void EveryPackagerShipsTheWholeShaderAndIncludeDirectoryAndProvesIt() + { + 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; + string name = Path.GetFileName(path); + packagers.Add(name); + + Assert.True(text.Contains("sources/shaderincludes", StringComparison.Ordinal), + name + " overlays sources/shaders but not sources/shaderincludes"); + // The overlay is a whole-directory copy, so a new stage ships without + // a packager edit - what needs proving is that the copy landed. + Assert.True(text.Contains("never reached the staged assets", StringComparison.Ordinal), + name + " does not verify that every source shader reached the stage"); + } + + foreach (string expected in new[] + { + "package-linux.sh", "package-macos.sh", "package-linux.ps1", + "package-macos.ps1", "package.ps1", + }) + { + Assert.Contains(expected, packagers); + } + } + + [Fact] + public void TheWindowsPackagerAssertsEveryTaaShaderInItsStagedTree() + { + string packager = Read("scripts/package.ps1"); + foreach (string staged in new[] + { + "assets/game/shaderincludes/vertexwarp.vsh", + "assets/game/shaders/taa-resolve.vsh", + "assets/game/shaders/taa-resolve.fsh", + "assets/game/shaders/taa-debug.vsh", + "assets/game/shaders/taa-debug.fsh", + "assets/game/shaders/taa-skymotion.vsh", + "assets/game/shaders/taa-skymotion.fsh", + // P5's post-resolve sharpen: a separate branch added the shader, this + // list came from another, and the wildcard overlay would have shipped + // it silently either way - the reviewer list is the only place the + // release states it is supposed to be there. + "assets/game/shaders/taa-sharpen.vsh", + "assets/game/shaders/taa-sharpen.fsh", + "assets/game/shaders/chunkliquidmotion.vsh", + "assets/game/shaders/chunkliquidmotion.fsh", + "assets/game/shaders/fsr-easu.vsh", + "assets/game/shaders/fsr-easu.fsh", + "assets/game/shaders/fsr-rcas.vsh", + "assets/game/shaders/fsr-rcas.fsh", + }) + { + Assert.Contains("'" + staged + "'", packager); + } + + // Everything the assertion names has to exist in the repository, or the + // list is a package failure waiting for the next release rather than a + // guard. + foreach (string staged in new[] + { + "taa-resolve.vsh", "taa-resolve.fsh", "taa-debug.vsh", "taa-debug.fsh", + "taa-skymotion.vsh", "taa-skymotion.fsh", + "taa-sharpen.vsh", "taa-sharpen.fsh", + "chunkliquidmotion.vsh", "chunkliquidmotion.fsh", + "fsr-easu.vsh", "fsr-easu.fsh", "fsr-rcas.vsh", "fsr-rcas.fsh", + }) + { + PatchReader.FindRepositoryFile("sources/shaders/" + staged); + } + } + + [Fact] + public void MakeDeployCopiesEveryShaderAndFailsWhenOneDoesNotArrive() + { + string makefile = Read("Makefile"); + + // Not "*.fsh plus *.vsh": both deploy paths copy the whole directory, and + // a stage that ships on only one of the two paths is exactly the bug the + // completeness check catches. The copy is a per-file loop whose cp failure + // aborts the target, so a copy error cannot be swallowed the way + // "find -exec cp" swallowed it. + Assert.Equal(2, Occurrences(makefile, "for f in sources/shaders/*;")); + Assert.Equal(2, Occurrences(makefile, "for f in sources/shaderincludes/*;")); + Assert.Contains("cp -f \"$$f\" \"$(VANILLA_DIR)/assets/game/shaders/$$(basename $$f)\" || exit 1", makefile); + Assert.Contains("cp -f \"$$f\" \"$(INSTALL_DIR)/assets/game/shaders/$$(basename $$f)\" || exit 1", makefile); + Assert.DoesNotContain("cp sources/shaders/*.fsh sources/shaders/*.vsh", makefile); + Assert.DoesNotContain("find sources/shaders", makefile); + Assert.Contains("mkdir -p $(VANILLA_DIR)/assets/game/shaderincludes", makefile); + Assert.Contains("mkdir -p $(INSTALL_DIR)/assets/game/shaderincludes", makefile); + + // 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. + // 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); + } + + // ---- helpers ----------------------------------------------------------- + + private static int Occurrences(string haystack, string needle) + { + int count = 0; + int index = 0; + while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += needle.Length; + } + + return count; + } + + private static string Between(string text, string start, string end) + { + int from = text.IndexOf(start, StringComparison.Ordinal); + Assert.True(from >= 0, "not found: " + start); + int to = text.IndexOf(end, from + start.Length, StringComparison.Ordinal); + Assert.True(to >= 0, "not found after " + start + ": " + end); + return text[from..to]; + } + + 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-sharpen-coverage-tests.cs b/Optimum.Tests/taa-sharpen-coverage-tests.cs new file mode 100644 index 00000000..690b66b7 --- /dev/null +++ b/Optimum.Tests/taa-sharpen-coverage-tests.cs @@ -0,0 +1,517 @@ +using System; +using System.IO; +using Vintagestory.API.Config; +using Xunit; + +namespace Optimum.Tests; + +/// +/// TAA-PLAN.md P5: the post-resolve sharpen pass (taa-sharpen) and the TAA mip +/// bias. Covers the pieces the GPU harness cannot see - registration, target +/// lifecycle, the pass's placement in the post chain, the no-double-sharpening +/// rule against FSR 1's RCAS, and the two LOD-bias call sites. +/// +public class TaaSharpenCoverageTests +{ + // --- the shader pair ---------------------------------------------------- + + [Fact] + public void SharpenShaderPairExistsAndIsAFullscreenTriangle() + { + string vertex = Read("sources/shaders/taa-sharpen.vsh"); + Assert.Contains("gl_VertexID", vertex); + // No vertex inputs: the pass is drawn with RenderFullscreenTriangle, + // which binds no vertex buffer on the device path. + Assert.DoesNotContain("in vec", vertex); + } + + [Fact] + public void SharpenShaderTakesASharpnessUniformInsteadOfTheBakedRcasConstant() + { + string fragment = Read("sources/shaders/taa-sharpen.fsh"); + Assert.Contains("uniform float sharpness;", fragment); + Assert.Contains("uniform sampler2D inputScene;", fragment); + Assert.Contains("uniform vec2 inputTexelSize;", fragment); + // fsr-rcas.fsh bakes the strength in as exp2(-0.2); this one must not. + Assert.DoesNotContain("lobe *= exp2(-0.2);", fragment); + Assert.Contains("strength * exp2(", fragment); + } + + [Fact] + public void SharpnessZeroIsATrueBypassBeforeAnyFilteringOrClamping() + { + string fragment = Read("sources/shaders/taa-sharpen.fsh"); + + int bypass = fragment.IndexOf("if (!(sharpness > 0.0))", StringComparison.Ordinal); + Assert.True(bypass >= 0, "the bypass must be an explicit early-out, not lobe = 0"); + // It returns the centre texel itself, unmodified, and does so before the + // first ring tap - otherwise "off" would not be bit-for-bit identical. + int returned = fragment.IndexOf("outColor = center;", bypass, StringComparison.Ordinal); + Assert.True(returned > bypass); + int firstTap = fragment.IndexOf("vec3 b = texture(", StringComparison.Ordinal); + Assert.True(returned < firstTap); + Assert.True(fragment.IndexOf("return;", returned, StringComparison.Ordinal) > returned); + } + + [Fact] + public void SharpenKeepsHdrRangeInsteadOfClampingToOne() + { + string fragment = Read("sources/shaders/taa-sharpen.fsh"); + // The resolve writes RGBA16F; fsr-rcas.fsh's clamp(x, 0, 1) would crush + // every value above 1 that reaches this pass. + Assert.DoesNotContain("clamp(sharpened, 0.0, 1.0)", fragment); + Assert.Contains("max(sharpened, vec3(0.0))", fragment); + } + + // --- registration ------------------------------------------------------- + + [Fact] + public void SharpenProgramIsRegisteredAsAnOptionalOptimumProgram() + { + string programs = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderPrograms.cs"); + Assert.Contains("public static ShaderProgram TaaSharpen;", programs); + + string registry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + Assert.Contains( + "RegisterOptimumShaderProgram(\"taa-sharpen\", ShaderPrograms.TaaSharpen = new ShaderProgram());", + registry); + // Optional exactly like taa-resolve: a failed compile sets LoadError on + // the program instead of failing the whole shader load. + Assert.Contains("shaderProgram == ShaderPrograms.TaaSharpen", registry); + } + + // --- target lifecycle --------------------------------------------------- + + [Fact] + public void SharpenTargetIsCreatedWithTheHistoryTargetsOnBothPaths() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.Contains("private const int OptimumTaaSharpenIndex = 21;", platform); + // 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(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 = 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); + Assert.True(historyGl >= 0 && sharpenGl > historyGl); + } + + [Fact] + public void ASharpenTargetFailureCostsTheSharpeningNotTaa() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // Neither failure path may call DisableOptimumTaa - TAA without the + // 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 ----------------------------------------------------------- + + [Fact] + public void SharpenRunsRightAfterTheResolveAndBeforeEveryConsumer() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + int resolve = platform.IndexOf("\t\tRenderOptimumTaaResolve();", StringComparison.Ordinal); + int sharpen = platform.IndexOf("postSceneTexture = RenderOptimumTaaSharpen(postSceneTexture);", StringComparison.Ordinal); + Assert.True(resolve >= 0); + int bloom = platform.IndexOf("if (RenderBloom)", resolve, StringComparison.Ordinal); + Assert.True(sharpen > resolve && bloom > sharpen); + // Reassigning postSceneTexture once is what gets the sharpened image to + // bloom (Findbright), god rays and the Luma copy the final composition + // reads, with no further call sites to keep in step. + Assert.Contains("findbright.ColorTex2D = postSceneTexture;", platform); + Assert.Contains("godrays.InputTexture2D = postSceneTexture;", platform); + Assert.Contains("blit.Scene2D = postSceneTexture;", platform); + } + + [Fact] + public void SharpenSkipsWhenThereIsNothingToSharpenAndRestoresRenderState() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + string body = MethodBody(platform, "public override int RenderOptimumTaaSharpen(int resolvedScene)"); + + 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 = 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));", draw); + } + + [Fact] + public void NoDoubleSharpeningWhenTheFsrRcasBlitIsActive() + { + string platform = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + // One shared condition, asked by both passes: the sharpen pass skips + // itself when the blit is going to run FSR's own RCAS at native + // resolution, so the same pixels are never sharpened twice. + Assert.Contains("public override bool OptimumFsrBlitActive()", platform); + Assert.Contains("bool useFsr = OptimumFsrBlitActive();", platform); + + string body = MethodBody(platform, "public override int RenderOptimumTaaSharpen(int resolvedScene)"); + int guard = body.IndexOf("if (OptimumFsrBlitActive())", 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); + + // The shared test still carries every term the old inline condition had. + string helper = MethodBody(platform, "public override bool OptimumFsrBlitActive()"); + Assert.Contains("!optimumFsrDisabled", helper); + Assert.Contains("ClientSettings.OptimumRenderScale < 1.0f", helper); + Assert.Contains("frameBuffers[OptimumFsrFramebufferIndex] != null", helper); + Assert.Contains("!fsrEasu.LoadError", helper); + Assert.Contains("!fsrRcas.LoadError", helper); + } + + // --- mip bias ----------------------------------------------------------- + + [Fact] + public void TerrainLodBiasIsZeroWithTaaOffAtNativeScale() + { + WithConfig(() => + { + OptimumConfig.Taa = false; + OptimumConfig.RenderScale = 1.0f; + OptimumConfig.TaaMipBias = -0.5f; + Assert.Equal(0f, OptimumConfig.EffectiveTerrainLodBias); + }); + } + + [Fact] + public void TerrainLodBiasAddsTheMipBiasWhileTaaIsOn() + { + WithConfig(() => + { + OptimumConfig.RenderScale = 1.0f; + OptimumConfig.TaaMipBias = -0.5f; + OptimumConfig.Taa = true; + Assert.Equal(-0.5f, OptimumConfig.EffectiveTerrainLodBias, 5); + + // And it adds to the render scale's own bias rather than replacing it. + OptimumConfig.RenderScale = 0.5f; + Assert.Equal(-1.5f, OptimumConfig.EffectiveTerrainLodBias, 5); + }); + } + + [Fact] + public void TerrainLodBiasKeepsTheRenderScaleTermWhenTaaIsOff() + { + WithConfig(() => + { + OptimumConfig.Taa = false; + OptimumConfig.TaaMipBias = -0.5f; + OptimumConfig.RenderScale = 0.5f; + Assert.Equal(-1f, OptimumConfig.EffectiveTerrainLodBias, 5); + }); + } + + [Fact] + public void TerrainLodBiasClampsTheConfiguredMipBias() + { + WithConfig(() => + { + OptimumConfig.RenderScale = 1.0f; + OptimumConfig.Taa = true; + OptimumConfig.TaaMipBias = -9f; + Assert.Equal(-2f, OptimumConfig.EffectiveTerrainLodBias, 5); + OptimumConfig.TaaMipBias = 9f; + Assert.Equal(1f, OptimumConfig.EffectiveTerrainLodBias, 5); + }); + } + + [Fact] + public void BothLodBiasCallSitesReadTheSharedValue() + { + string chunkRenderer = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + Assert.Contains( + "float textureLodBias = Vintagestory.API.Config.OptimumConfig.EffectiveTerrainLodBias;", + chunkRenderer); + // A total of zero still makes no TexParameter call at all, which is what + // keeps TAA off at native scale identical to vanilla. + Assert.Contains("if (textureLodBias == 0f)", chunkRenderer); + // The zero branch is not a plain guard: it restores. optimumTextureLodBias + // caches the last applied value starting at NaN, so a nonzero -> zero + // transition (TAA switched off, render scale back to 1.0) writes 0 back + // through SetOptimumTextureLodBias - which resets the atlas texture + // parameter AND, through ShaderRegistry.ApplyOptimumTerrainSamplerLodBias, + // the two terrain sampler objects - before returning. + Assert.Contains("private float optimumTextureLodBias = float.NaN;", chunkRenderer); + string zeroBranch = BranchAfter(chunkRenderer, "if (textureLodBias == 0f)"); + Assert.Contains("if (!float.IsNaN(optimumTextureLodBias))", zeroBranch); + Assert.Contains("SetOptimumTextureLodBias(0f);", zeroBranch); + Assert.Contains("optimumTextureLodBias = float.NaN;", zeroBranch); + // ...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: 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", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + // The sampler objects override the texture parameter on the units they + // are bound to, so they must carry the same bias. + Assert.Contains("float terrainLodBias = OptimumConfig.EffectiveTerrainLodBias;", registry); + Assert.Contains("if (terrainLodBias != 0f)", registry); + // The load-time call skips zero (vanilla makes no such call), but the + // shared entry point applies whatever it is handed - the restore above + // hands it 0f and must reach the samplers. + string samplerEntry = BranchAfter(registry, "public static void ApplyOptimumTerrainSamplerLodBias(float bias)"); + Assert.DoesNotContain("!= 0f", samplerEntry); + Assert.Equal(4, Count(samplerEntry, ", bias);")); + } + + /// + /// P5 review: the mip-bias row claims to apply live, and for the two programs + /// the setting exists for it did not. chunkopaque and chunktopsoil sample the + /// atlas through sampler OBJECTS, and a bound sampler object overrides the + /// texture object's parameters on that unit - LOD bias included. So + /// ChunkRenderer's per-frame TexParameter moved the mip selection of liquid, + /// transparent and shadow terrain while the two opaque passes kept whatever + /// bias the last shader load compiled in. Both halves now move together. + /// + [Fact] + public void ALiveMipBiasChangeReachesTheTerrainSamplerObjectsAsWell() + { + string registry = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ShaderRegistry.cs"); + // 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 and the platform virtual. + Assert.Contains("platform.SetSamplerLodBias(sampler, bias);", registry); + Assert.Contains( + "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); + + string chunkRenderer = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + string setter = MethodBody(chunkRenderer, "private void SetOptimumTextureLodBias(float bias)"); + Assert.Contains("ShaderRegistry.ApplyOptimumTerrainSamplerLodBias(bias);", setter); + // Before the texture half, and on every backend: both go through the platform. + Assert.True( + setter.IndexOf("ShaderRegistry.ApplyOptimumTerrainSamplerLodBias(bias);", 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"); + Assert.Contains("\"ApplyOptimumTerrainSamplerLodBias\"", patcher); + Assert.Contains("\"ApplyOptimumSamplerLodBias\"", patcher); + } + + /// + /// P5 review: with a 0f initialiser the very first OnBeforeRenderOpaque of a + /// TAA-off, native-scale session sees "0 wanted, not-NaN cached" and writes an + /// explicit LOD bias of 0 over the driver default on every atlas - and, since + /// the fix above, on every terrain sampler too. NaN is what "Optimum has never + /// touched this" has to mean for that configuration to make no call at all. + /// + [Fact] + public void TheCachedLodBiasStartsAtNanSoTaaOffTouchesNothing() + { + string chunkRenderer = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + Assert.Contains("private float optimumTextureLodBias = float.NaN;", chunkRenderer); + Assert.DoesNotContain("private float optimumTextureLodBias;", chunkRenderer); + } + + // --- manifests and scanner --------------------------------------------- + + [Fact] + public void CecilPatcherShipsTheSharpenMembers() + { + string patcher = Read("Optimum.Patcher/Program.cs"); + Assert.Contains("\"OptimumTaaSharpenIndex\"", patcher); + Assert.Contains("\"OptimumFsrBlitActive\"", patcher); + Assert.Contains("\"RenderOptimumTaaSharpen\"", patcher); + Assert.Contains("\"TaaSharpen\"", patcher); + // The bodies the calls live in are transplanted. + Assert.Contains("\"Vintagestory.Client.NoObf.ClientPlatformWindows\", \"RenderPostprocessingEffects\", 1", 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\", \"loadRegisteredShaderPrograms\", 0", patcher); + Assert.Contains("\"ApplyOptimumTextureLodBias\"", patcher); + } + + [Fact] + public void ScannerVetoesTaaWhenAModOwnsOneOfItsOwnPasses() + { + string scanner = Read("Optimum.Launcher/ShaderCompatibilityScanner.cs"); + Assert.Contains("HasExternalShader(report, \"taa-sharpen.vsh\")", scanner); + Assert.Contains("HasExternalShader(report, \"taa-sharpen.fsh\")", scanner); + // The resolve and the debug view were missing from the same list. + Assert.Contains("HasExternalShader(report, \"taa-resolve.fsh\")", scanner); + Assert.Contains("HasExternalShader(report, \"taa-debug.fsh\")", scanner); + } + + // --- helpers ------------------------------------------------------------ + + /// + /// The text from a method's signature to the start of the next member + /// declaration at the same indentation ("\n\t}" followed by a blank line). + /// + 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); + } + + /// + /// The brace-delimited block that follows , matched + /// by brace depth so a nested block cannot end it early. + /// + private static string BranchAfter(string source, string header) + { + int start = source.IndexOf(header, StringComparison.Ordinal); + Assert.True(start >= 0, "not found: " + header); + int open = source.IndexOf('{', start + header.Length); + Assert.True(open > start, "no block after: " + header); + 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); + } + Assert.Fail("unbalanced block after: " + header); + return string.Empty; + } + + private static void WithConfig(Action body) + { + bool taa = OptimumConfig.Taa; + float scale = OptimumConfig.RenderScale; + float mip = OptimumConfig.TaaMipBias; + try + { + body(); + } + finally + { + OptimumConfig.Taa = taa; + OptimumConfig.RenderScale = scale; + OptimumConfig.TaaMipBias = mip; + } + } + + 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; + } + } + + /// + /// 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/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..e84ee6ef --- /dev/null +++ b/Optimum.Tests/taa-sky-decal-motion-coverage-tests.cs @@ -0,0 +1,645 @@ +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 + + /// + /// The sky direction is far point MINUS near point. CameraMatrixOrigin is a + /// look-at with the eye at LocalEyePos, ~1.7 blocks above the origin the + /// terrain is drawn relative to, so a reconstructed far point's position + /// vector is not the view direction: it carries the eye offset, which + /// projected into a fixed ~0.6 px vertical error on every sky vector on + /// both backends (measured 2026-09-11, eye / far * rows / 2 / tan(fov / 2)). + /// Both consumers - the sky pass and the resolve's own sky branch - take the + /// homogeneous difference of the two reconstructed points. + /// + [Fact] + public void TheSkyDirectionIsFarMinusNearInBothConsumers() + { + string sky = Read("sources/shaders/taa-skymotion.fsh"); + string resolve = Read("sources/shaders/taa-resolve.fsh"); + + Assert.Contains("vec4 nearH = taaInvViewProjJittered * vec4(ndc, -1.0, 1.0);", sky); + Assert.Contains("vec3 direction = farH.xyz * nearH.w - nearH.xyz * farH.w;", sky); + 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); + + // 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); + } + + /// + /// 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, "public override 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); + } + + /// + /// 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, "public override 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 + + /// + /// 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!)); + } + + /// + /// 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); + // 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. + 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);", + // 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); + } + + string unguarded = pass.Substring(begin, tryStart - begin); + Assert.DoesNotContain("GlToggleBlend", unguarded); + Assert.DoesNotContain("shaderProgramDecals", unguarded); + } + + // ------------------------------- (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 override bool BeginMotionWrite()", + "public override 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)))); + } + + /// + /// 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) + { + 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); + } + + /// 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); + } + + 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/taa-standard-motion-coverage-tests.cs b/Optimum.Tests/taa-standard-motion-coverage-tests.cs new file mode 100644 index 00000000..759bbcaf --- /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, 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 + // 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("sources/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/Optimum.Tests/taa-terrain-motion-coverage-tests.cs b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs new file mode 100644 index 00000000..7b2127fe --- /dev/null +++ b/Optimum.Tests/taa-terrain-motion-coverage-tests.cs @@ -0,0 +1,732 @@ +using System; +using System.Collections.Generic; +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() + { + // 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[] + { + ("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 override bool BeginMotionWrite()", platform); + Assert.Contains("public override void EndMotionWrite()", platform); + Assert.Contains("public override bool OptimumMotionWriteActive", 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 (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( + "StateDrawBuffers(FrameBuffers[0].FboId, (1 << (MotionAttachmentIndex + 1)) - 1);", + vulkan); + Assert.Contains( + "StateDrawBuffers(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 + // 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 override bool BeginMotionWrite()", 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[")); + 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 override bool BeginMotionWrite()", StringComparison.Ordinal); + Assert.True(begin >= 0); + int drawBuffers = platform.IndexOf("EnableMotionDrawBuffers();", begin, StringComparison.Ordinal); + Assert.True(drawBuffers > begin); + + string guards = platform.Substring(begin, drawBuffers - begin); + Assert.Contains("if (!ReferenceEquals(CurrentFrameBuffer, frameBuffers[0])) return false;", guards); + } + + /// + /// 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"); + + // 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("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); + 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. 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); + 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")); + } + + // ------------------------------------------------------------- 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); + } + + /// + /// Both motion windows are exception-safe. BeginMotionWrite expands the + /// draw-buffer mask and refuses to open a second window; if a shader setup + /// or a pool draw throws, an End outside a finally never runs and the + /// expanded mask leaks into every later draw while every later window is + /// refused. The liquid pass already closes its window in a finally; these + /// two now match it. + /// + [Fact] + public void BothTerrainMotionWindowsCloseInAFinally() + { + string chunk = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + foreach (string signature in new[] + { + "public void RenderOpaque(float dt)", + "internal void RenderAfterOIT(float deltaTime)", + }) + { + string body = MethodBodyAfter(chunk, signature); + + int begin = body.IndexOf("optimumPlatform.BeginMotionWrite();", StringComparison.Ordinal); + Assert.True(begin >= 0, signature + " no longer opens a motion window"); + + // The window is opened, then immediately entered with try, and the + // only End in the method sits inside the following finally block. + int tryStart = body.IndexOf("try", begin, StringComparison.Ordinal); + int finallyStart = body.IndexOf("finally", begin, StringComparison.Ordinal); + int end = body.IndexOf("optimumPlatform.EndMotionWrite();", begin, StringComparison.Ordinal); + + Assert.True(tryStart > begin, signature + " does not open a try after BeginMotionWrite"); + Assert.True(finallyStart > tryStart, signature + " has no finally for the motion window"); + Assert.True(end > finallyStart, signature + " closes the motion window outside the finally"); + Assert.Equal(1, Count(body, "optimumPlatform.EndMotionWrite();")); + } + + // The liquid pass keeps its own finally-closed window (the pattern these + // two copy). + string liquid = MethodBodyAfter(chunk, "internal void RenderLiquidMotion(float deltaTime)"); + Assert.Contains("finally", liquid); + Assert.Contains("optimumPlatform.EndMotionOnlyWrite();", liquid); + } + + /// + /// 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 = ReadPatchedOrSource( + "patches/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs.patch", + "build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + + string prepass = MethodBodyAfter(chunk, "public void OnRenderBefore(float dt)"); + + Assert.Contains("EnumFrameBuffer.LiquidDepth", prepass); + Assert.DoesNotContain("BeginMotionWrite", prepass); + Assert.DoesNotContain("SetOptimumMotionUniforms", prepass); + + // The projection assignment itself is vanilla, so it sits outside every + // hunk and cannot be asserted on patch-derived text. Check it against the + // decompiled tree wherever that is checked out (build/ is git-ignored). + string? decompiled = TryFind("build/VintagestoryLib/Vintagestory.Client.NoObf/ChunkRenderer.cs"); + if (decompiled != null) + { + string full = MethodBodyAfter(File.ReadAllText(decompiled), "public void OnRenderBefore(float dt)"); + Assert.Contains("chunkliquiddepth.ProjectionMatrix = game.CurrentProjectionMatrix;", full); + Assert.DoesNotContain("BeginMotionWrite", full); + Assert.DoesNotContain("SetOptimumMotionUniforms", full); + } + } + + // ------------------------------------------------------ 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". + /// + /// + /// 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() + { + 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")); + // Two deploy destinations (the .vanilla run tree and an installed + // runtime), each naming the directory twice since P5: a mkdir -p before + // the copy, because a vanilla tree that has no shaderincludes directory + // turns "cp sources/shaderincludes/*" into a file of that name. + Assert.Equal(4, Count(Read("Makefile"), "assets/game/shaderincludes")); + + // 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"); + + // 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 + // above passes vacuously. + foreach (string expected in new[] + { + "package-linux.sh", "package-macos.sh", "package-linux.ps1", + "package-macos.ps1", "package.ps1", + }) + { + 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"))); + } + + /// + /// 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/Optimum.Tests/temporal-contract-tests.cs b/Optimum.Tests/temporal-contract-tests.cs new file mode 100644 index 00000000..c69e4d34 --- /dev/null +++ b/Optimum.Tests/temporal-contract-tests.cs @@ -0,0 +1,718 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Vintagestory.API.Client; +using Xunit; + +namespace Optimum.Tests; + +/// +/// Contract-stability tests for docs/temporal-frame-contract.md (v1). +/// +/// The document is the frozen specification every temporal consumer is written +/// against - the in-house TAA resolve today, FSR 3.1 / XeSS 2 / DLSS next, frame +/// generation and ray reconstruction after that. These tests are its tripwire: +/// they pin the public surface of the input record, and the conventions the +/// document states as fact, to the code that implements them. Every failure +/// message names the document, because a change here is a change to the contract +/// and has to be written down there (and versioned) before it is trusted. +/// +/// None of this is behaviour coverage. It is "the document still describes the +/// code", which is the only thing that makes freezing a contract worth anything. +/// +public class TemporalContractTests +{ + private const string Doc = "docs/temporal-frame-contract.md"; + private const string ContractVersion = "v1"; + + // --------------------------------------------------------------------- + // 1. The public surface of the input record + // --------------------------------------------------------------------- + + /// + /// Every public member of IOptimumTemporalContext, as + /// kind returnType Name(parameterTypes), ordinal-sorted. This is the + /// checked-in list section 1.1 of the contract documents member by member. + /// + private static readonly string[] ExpectedContextSurface = + { + "method EnumTemporalResetReason get_ResetReason()", + "method EnumTemporalView get_ActiveView()", + "method Boolean IsViewCaptured(EnumTemporalView)", + "method Boolean get_JitterActive()", + "method Boolean get_Reset()", + "method Int32 get_RenderHeight()", + "method Int32 get_RenderWidth()", + "method Int64 get_FrameIndex()", + "method OptimumWarpState get_PrevWarp()", + "method OptimumWarpState get_Warp()", + "method Single get_DeltaTimeMs()", + "method Single get_Fov()", + "method Single get_ZFar()", + "method Single get_ZNear()", + "method Single[] GetPrevProjection(EnumTemporalView)", + "method Single[] GetProjection(EnumTemporalView)", + "method Single[] get_CameraMatrix()", + "method Single[] get_CameraMatrixOrigin()", + "method Single[] get_PrevCameraMatrix()", + "method Single[] get_PrevCameraMatrixOrigin()", + "method Vec2f get_JitterPx()", + "method Vec2f get_JitterSequencePx()", + "method Vec2f get_PrevJitterPx()", + "method Vec3f get_CameraPosDelta()", + "method Vec3f get_Playerpos()", + "method Vec3f get_PrevPlayerpos()", + "property EnumTemporalResetReason ResetReason get", + "property EnumTemporalView ActiveView get", + "property Boolean JitterActive get", + "property Boolean Reset get", + "property Int32 RenderHeight get", + "property Int32 RenderWidth get", + "property Int64 FrameIndex get", + "property OptimumWarpState PrevWarp get", + "property OptimumWarpState Warp get", + "property Single DeltaTimeMs get", + "property Single Fov get", + "property Single ZFar get", + "property Single ZNear get", + "property Single[] CameraMatrix get", + "property Single[] CameraMatrixOrigin get", + "property Single[] PrevCameraMatrix get", + "property Single[] PrevCameraMatrixOrigin get", + "property Vec2f JitterPx get", + "property Vec2f JitterSequencePx get", + "property Vec2f PrevJitterPx get", + "property Vec3f CameraPosDelta get", + "property Vec3f Playerpos get", + "property Vec3f PrevPlayerpos get", + }; + + /// + /// The same for the concrete frame. It carries the mutators the client owns - + /// Advance, the two captures, RecordProjection - which consumers must never + /// call; the contract says so, and this list is why a new one cannot appear + /// without the document being edited. + /// + private static readonly string[] ExpectedFrameSurface = + { + "ctor .ctor()", + "field Double TeleportThresholdBlocks", + "method Boolean IsViewCaptured(EnumTemporalView)", + "method Boolean WasViewCaptured(EnumTemporalView)", + "method Boolean get_JitterActive()", + "method EnumTemporalResetReason get_ResetReason()", + "method EnumTemporalView get_ActiveView()", + "method Boolean get_Reset()", + "method Int32 get_RenderHeight()", + "method Int32 get_RenderWidth()", + "method Int64 get_FrameIndex()", + "method OptimumWarpState get_PrevWarp()", + "method OptimumWarpState get_Warp()", + "method Single get_DeltaTimeMs()", + "method Single get_Fov()", + "method Single get_ZFar()", + "method Single get_ZNear()", + "method Single[] ApplyJitterCopy(Double[])", + "method Single[] GetPrevProjection(EnumTemporalView)", + "method Single[] GetProjection(EnumTemporalView)", + "method Single[] get_CameraMatrix()", + "method Single[] get_CameraMatrixOrigin()", + "method Single[] get_PrevCameraMatrix()", + "method Single[] get_PrevCameraMatrixOrigin()", + "method Vec2f get_JitterPx()", + "method Vec2f get_JitterSequencePx()", + "method Vec2f get_PrevJitterPx()", + "method Vec3f get_CameraPosDelta()", + "method Vec3f get_Playerpos()", + "method Vec3f get_PrevPlayerpos()", + "method Void Advance(Single, Int32, Int32, Single, Single, Single, Single, DefaultShaderUniforms)", + "method Void ApplyMotionUniforms(IShaderProgram)", + "method Void CaptureCamera(Double[], Double[])", + "method Void CaptureCameraPosition(Vec3d, DefaultShaderUniforms)", + "method Void RecordProjection(EnumTemporalView, Double[])", + "method Void RequestReset(EnumTemporalResetReason)", + "method Void set_JitterActive(Boolean)", + "property EnumTemporalResetReason ResetReason get", + "property EnumTemporalView ActiveView get", + "property Boolean JitterActive get set", + "property Boolean Reset get", + "property Int32 RenderHeight get", + "property Int32 RenderWidth get", + "property Int64 FrameIndex get", + "property OptimumWarpState PrevWarp get", + "property OptimumWarpState Warp get", + "property Single DeltaTimeMs get", + "property Single Fov get", + "property Single ZFar get", + "property Single ZNear get", + "property Single[] CameraMatrix get", + "property Single[] CameraMatrixOrigin get", + "property Single[] PrevCameraMatrix get", + "property Single[] PrevCameraMatrixOrigin get", + "property Vec2f JitterPx get", + "property Vec2f JitterSequencePx get", + "property Vec2f PrevJitterPx get", + "property Vec3f CameraPosDelta get", + "property Vec3f Playerpos get", + "property Vec3f PrevPlayerpos get", + }; + + [Fact] + public void TheTemporalContextSurfaceIsFrozen() + { + AssertSurface(typeof(IOptimumTemporalContext), ExpectedContextSurface, nameof(ExpectedContextSurface)); + } + + [Fact] + public void TheTemporalFrameSurfaceIsFrozen() + { + AssertSurface(typeof(OptimumTemporalFrame), ExpectedFrameSurface, nameof(ExpectedFrameSurface)); + } + + [Fact] + public void TheFrameIsTheContext() + { + Assert.True( + typeof(IOptimumTemporalContext).IsAssignableFrom(typeof(OptimumTemporalFrame)), + $"{Doc} specifies OptimumTemporal.Context as the read-only view of OptimumTemporal.Frame."); + Assert.Same(OptimumTemporal.Frame, OptimumTemporal.Context); + } + + /// + /// The warp snapshot is the whole reason a writer can evaluate the vertex warp + /// twice; section 1.2 lists its fields one by one. + /// + [Fact] + public void TheWarpStateFieldsAreFrozen() + { + string[] expected = + { + "field Single GlitchWaviness", + "field Single GlobalWarpIntensity", + "field Int32 PerceptionEffectId", + "field Single PerceptionEffectIntensity", + "field Single TimeCounter", + "field Single WaterWaveCounter", + "field Single WaterWaveIntensity", + "field Single WindSpeed", + "field Single WindWaveCounter", + "field Single WindWaveCounterHighFreq", + "field Single WindWaveIntensity", + }; + + string[] actual = Surface(typeof(OptimumWarpState)) + .Where(entry => entry.StartsWith("field ", StringComparison.Ordinal)) + .ToArray(); + + AssertSetsMatch(expected, actual, "OptimumWarpState", nameof(TheWarpStateFieldsAreFrozen)); + } + + /// + /// Section 5 lists the reset reasons in declaration order, with a trigger for + /// each. A new reason means a new trigger row and a contract version bump. + /// + [Fact] + public void TheResetReasonsAreFrozenAndDocumented() + { + string[] expected = + { + "None", "WorldLoad", "Dimension", "Teleport", "Rebase", "Resize", + "ShaderReload", "FovChange", "RenderScale", "Toggle", "Screenshot", + "CameraHistoryLost", + }; + + string[] actual = Enum.GetNames(typeof(EnumTemporalResetReason)); + Assert.True( + expected.SequenceEqual(actual), + $"EnumTemporalResetReason changed. {Doc} section 5 lists the reasons in declaration " + + $"order with their triggers; update it and bump the contract version.\nexpected: " + + $"{string.Join(", ", expected)}\nactual: {string.Join(", ", actual)}"); + + string doc = ReadDoc(); + foreach (string reason in actual) + { + Assert.True(doc.Contains("`" + reason + "`", StringComparison.Ordinal), + $"{Doc} section 5 does not document the reset reason {reason}."); + } + + string[] views = Enum.GetNames(typeof(EnumTemporalView)); + Assert.True(new[] { "World", "Hand" }.SequenceEqual(views), + $"EnumTemporalView changed; {Doc} section 1.1 documents exactly the World and Hand views."); + } + + // --------------------------------------------------------------------- + // 2. Jitter: the shear formula and the sequence + // --------------------------------------------------------------------- + + /// + /// Section 2 states the shear as P[8] -= 2*jx/W, P[9] -= 2*jy/H, and states + /// what the number means: JitterPx is the raster displacement of a static + /// point. The formula is pinned in the one implementation, in the document, + /// and numerically - because a sign flip here silently inverts every motion + /// vector's jitter removal. + /// + [Fact] + public void TheJitterShearFormulaIsTheOneTheContractStates() + { + string math = Read("VintagestoryApi/Client/Render/OptimumTemporalMath.cs"); + Assert.True(math.Contains("projection[8] -= 2.0 * jitterX / renderWidth;", StringComparison.Ordinal), + $"OptimumTemporalMath.ApplyProjectionJitter no longer matches the shear {Doc} section 2 freezes."); + Assert.True(math.Contains("projection[9] -= 2.0 * jitterY / renderHeight;", StringComparison.Ordinal), + $"OptimumTemporalMath.ApplyProjectionJitter no longer matches the shear {Doc} section 2 freezes."); + + string frame = Read("VintagestoryApi/Client/Render/OptimumTemporalFrame.cs"); + Assert.True(frame.Contains("jitteredScratch[8] -= (float)(2.0 * JitterPx.X / RenderWidth);", StringComparison.Ordinal), + $"OptimumTemporalFrame.ApplyJitterCopy diverged from the shear {Doc} section 2 freezes."); + Assert.True(frame.Contains("jitteredScratch[9] -= (float)(2.0 * JitterPx.Y / RenderHeight);", StringComparison.Ordinal), + $"OptimumTemporalFrame.ApplyJitterCopy diverged from the shear {Doc} section 2 freezes."); + + string doc = ReadDoc(); + Assert.True(doc.Contains("P[8] -= 2 * jx / renderWidth;", StringComparison.Ordinal), + $"{Doc} section 2 no longer states the shear formula."); + Assert.True(doc.Contains("P[9] -= 2 * jy / renderHeight;", StringComparison.Ordinal), + $"{Doc} section 2 no longer states the shear formula."); + + // And the meaning of the sign: a static point moves by +jx raster pixels. + const double width = 1920.0; + const double height = 1080.0; + double[] unjittered = Perspective(width, height); + double[] jittered = (double[])unjittered.Clone(); + OptimumTemporalMath.ApplyProjectionJitter(jittered, 0.37, -0.21, width, height); + + (double x0, double y0) = ProjectToPixel(unjittered, 2.5, -1.25, -12.0, width, height); + (double x1, double y1) = ProjectToPixel(jittered, 2.5, -1.25, -12.0, width, height); + Assert.Equal(0.37, x1 - x0, 9); + Assert.Equal(-0.21, y1 - y0, 9); + } + + /// + /// Section 2's phase count: max(1, ceil(8 * upscale^2)), 8 at native and 32 at + /// render scale 0.5. The vendor adapters may read their own phase count from + /// the SDK instead, which is why the shape is written down. + /// + [Fact] + public void TheJitterPhaseCountIsTheOneTheContractStates() + { + Assert.Equal(8, OptimumTemporalMath.JitterPhaseCount(1f)); + Assert.Equal(32, OptimumTemporalMath.JitterPhaseCount(2f)); // render scale 0.5 -> upscale 2 + + string doc = ReadDoc(); + Assert.True(doc.Contains("ceil(8 * upscale^2)", StringComparison.Ordinal), + $"{Doc} section 2 no longer states the jitter phase count."); + } + + // --------------------------------------------------------------------- + // 3. Motion vector adapters + // --------------------------------------------------------------------- + + /// + /// Section 7.1: the stored vector is previousPixel - currentPixel in render + /// pixels, and each adapter's scale and sign. The sign is the part a vendor + /// integration cannot discover from a smeared image, so each adapter is + /// checked for direction as well as magnitude. + /// + [Theory] + [InlineData(OptimumTemporalMath.MotionVectorAdapter.Fsr, 1.0f, 1.0f)] + [InlineData(OptimumTemporalMath.MotionVectorAdapter.Xess, 1.0f, 1.0f)] + [InlineData(OptimumTemporalMath.MotionVectorAdapter.Dlss, 1.0f / 1920f, 1.0f / 1080f)] + public void EachAdapterScalesAndSignsTheMotionVectorAsTheContractStates( + OptimumTemporalMath.MotionVectorAdapter adapter, float scaleX, float scaleY) + { + const int width = 1920; + const int height = 1080; + + // A surface that moved right and down on screen: it WAS three pixels left + // and two pixels below, so the stored vector (previous - current) is + // (-3, -2). No adapter flips that sign; only the scale differs. + (float x, float y) = OptimumTemporalMath.AdaptMotionVector(-3f, -2f, width, height, adapter); + Assert.Equal(-3f * scaleX, x, 6); + Assert.Equal(-2f * scaleY, y, 6); + + (float px, float py) = OptimumTemporalMath.AdaptMotionVector(4f, 5f, width, height, adapter); + Assert.Equal(4f * scaleX, px, 6); + Assert.Equal(5f * scaleY, py, 6); + + Assert.True(x < 0f && y < 0f, + $"{Doc} section 7.1 states that no adapter flips the motion vector's sign."); + } + + [Fact] + public void TheAdapterSetIsFrozenAndDocumented() + { + string[] expected = { "Fsr", "Dlss", "Xess" }; + string[] actual = Enum.GetNames(typeof(OptimumTemporalMath.MotionVectorAdapter)); + Assert.True(expected.OrderBy(n => n, StringComparer.Ordinal) + .SequenceEqual(actual.OrderBy(n => n, StringComparer.Ordinal)), + $"The adapter set changed; {Doc} section 7 has one row per consumer and must be updated."); + + string doc = ReadDoc(); + Assert.True(doc.Contains("motionVectorScale = (1, 1)", StringComparison.Ordinal), + $"{Doc} section 7.1 no longer states FSR 3.1's motion vector scale."); + Assert.True(doc.Contains("mvecScale = (1 / renderWidth, 1 / renderHeight)", StringComparison.Ordinal), + $"{Doc} section 7.1 no longer states DLSS's motion vector scale."); + Assert.True(doc.Contains("XESS_INIT_FLAG_USE_NDC_VELOCITY", StringComparison.Ordinal), + $"{Doc} section 7.1 no longer states XeSS 2's velocity units."); + Assert.True(doc.Contains("depthInverted = false", StringComparison.Ordinal), + $"{Doc} section 7.3 no longer states the depth convention handed to DLSS."); + } + + // --------------------------------------------------------------------- + // 4. The writer-depth validity tolerance + // --------------------------------------------------------------------- + + /// + /// Section 3.2's validity rule, verbatim. This one expression decides whether + /// a pixel uses its writer's vector or the camera fallback, so it is the + /// single most load-bearing line in the whole contract: loosening it accepts + /// a writer that belongs to hidden geometry, tightening it demoted every + /// close decal to the fallback (P4 finding (o)). + /// + [Fact] + 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(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."); + // 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."); + } + + // --------------------------------------------------------------------- + // 5. Resources: history slots, sharpen slot, attachment formats + // --------------------------------------------------------------------- + + /// + /// Section 3.3 and 3.4: history slot indices 19 and 20, the sharpen target 21, + /// and the parity rule that decides which of the two is written. A consumer + /// that wants the resolved image reads the slot the parity names. + /// + [Fact] + public void TheHistoryAndSharpenSlotIndicesAreFrozen() + { + string platform = Read("build/VintagestoryLib/Vintagestory.Client.NoObf/ClientPlatformWindows.cs"); + + Assert.True(platform.Contains("private const int OptimumTaaHistoryIndexA = 19;", StringComparison.Ordinal), + $"The TAA history slot A index moved; {Doc} section 3.3 names it."); + Assert.True(platform.Contains("private const int OptimumTaaHistoryIndexB = 20;", StringComparison.Ordinal), + $"The TAA history slot B index moved; {Doc} section 3.3 names it."); + Assert.True(platform.Contains("private const int OptimumTaaSharpenIndex = 21;", StringComparison.Ordinal), + $"The TAA sharpen target index moved; {Doc} section 3.4 names it."); + Assert.True(platform.Contains( + "return frameBuffers[(parity & 1) == 0 ? OptimumTaaHistoryIndexA : OptimumTaaHistoryIndexB];", + StringComparison.Ordinal), + $"The history parity rule changed; {Doc} section 3.3 states it."); + Assert.True(platform.Contains("FrameBufferRef write = TaaHistory(_taaFrameParity);", StringComparison.Ordinal) + && platform.Contains("FrameBufferRef read = TaaHistory(_taaFrameParity + 1);", StringComparison.Ordinal), + $"The resolve's read/write slot selection changed; {Doc} section 3.3 states it."); + + string doc = ReadDoc(); + Assert.True(doc.Contains("frame buffer slots 19 and 20", StringComparison.Ordinal), + $"{Doc} section 3.3 no longer names the two history slots by index."); + Assert.True(doc.Contains("frame buffer slot 21", StringComparison.Ordinal), + $"{Doc} section 3.4 no longer names the sharpen slot by index."); + } + + /// + /// Section 3.1 and 3.3: the attachment formats and the sampler state that goes + /// with them, on BOTH backends. The filters are load-bearing - history colour + /// and glow are read at a fractional reprojected offset, and an interpolated + /// linear depth across a silhouette belongs to neither surface. GL_R32F going + /// missing from the device enum map once turned the depth history into RGBA8 + /// in silence (P2 finding (a)), which is why the formats are pinned and not + /// left to a comment. + /// + [Fact] + 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(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), + $"The GL path no longer creates the motion attachment as RGBA16F (34842); {Doc} section 3.1 freezes the format."); + Assert.True(platform.Contains("public override int MotionAttachmentIndex", StringComparison.Ordinal) + && platform.Contains("private int optimumMotionAttachmentIndex = -1;", StringComparison.Ordinal), + $"MotionAttachmentIndex changed shape; {Doc} section 3.1 describes it as 2 without SSAO, 4 with, -1 when off."); + + // Primary depth: DepthComponent32 on the device path, 33191 = GL_DEPTH_COMPONENT32 on GL, + // NEAREST + CLAMP_TO_EDGE on both. + 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(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) + && 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(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(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."); + + // Same slot, GL path: 34842 = RGBA16F, 32856 = RGBA8, OptimumGlR32f, with + // matching filters (9729 LINEAR / 9728 NEAREST) and 33071 CLAMP_TO_EDGE. + Assert.True(platform.Contains("GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)34842, width, height, 0, (PixelFormat)6408, (PixelType)5126, (IntPtr)IntPtr.Zero);", StringComparison.Ordinal), + $"The GL history colour attachment is no longer RGBA16F; {Doc} section 3.3 freezes it."); + Assert.True(platform.Contains("GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)32856, width, height, 0, (PixelFormat)6408, (PixelType)5121, (IntPtr)IntPtr.Zero);", StringComparison.Ordinal), + $"The GL history aux attachment is no longer RGBA8; {Doc} section 3.3 freezes it."); + Assert.True(platform.Contains("GL.TexImage2D((TextureTarget)3553, 0, (PixelInternalFormat)OptimumGlR32f, width, height, 0, (PixelFormat)6403, (PixelType)5126, (IntPtr)IntPtr.Zero);", StringComparison.Ordinal), + $"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(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."); + } + + /// + /// Section 4: the resolve consumes exactly the contract. If a new input + /// appears in the resolve that the contract does not describe, the contract + /// is no longer the superset it claims to be. + /// + [Fact] + public void TheResolveConsumesOnlyWhatTheContractDescribes() + { + string resolve = Read("sources/shaders/taa-resolve.fsh"); + string[] samplers = + { + "sceneTex", "glowTex", "motionTex", "depthTex", + "historyColor", "historyGlow", "historyDepth", + }; + string[] uniforms = + { + "renderSize", "jitterPx", "invViewProjJittered", "prevViewProj", + "viewMatrix", "cameraDelta", "resetHistory", "blendAlpha", "varianceGamma", + }; + + string doc = ReadDoc(); + foreach (string name in samplers.Concat(uniforms)) + { + Assert.True(resolve.Contains(name, StringComparison.Ordinal), + $"taa-resolve.fsh lost the input {name}; {Doc} section 4 tabulates it."); + Assert.True(doc.Contains("`" + name + "`", StringComparison.Ordinal), + $"{Doc} section 4 does not document the resolve input {name}."); + } + + Assert.True(resolve.Contains("layout(location = 0) out vec4 outColor;", StringComparison.Ordinal) + && resolve.Contains("layout(location = 1) out vec4 outGlow;", StringComparison.Ordinal) + && resolve.Contains("layout(location = 2) out vec4 outDepth;", StringComparison.Ordinal), + $"The resolve's MRT layout changed; {Doc} section 4 maps the three outputs onto the history slot."); + } + + // --------------------------------------------------------------------- + // 6. The document itself + // --------------------------------------------------------------------- + + [Fact] + public void TheContractDocumentIsVersionedAndComplete() + { + string doc = ReadDoc(); + + Assert.True(doc.Contains("**Version:** `" + ContractVersion + "`", StringComparison.Ordinal), + $"{Doc} no longer declares its version. A contract without a version cannot be frozen."); + Assert.True(doc.Contains("Optimum.Tests/temporal-contract-tests.cs", StringComparison.Ordinal), + $"{Doc} no longer names its own stability test."); + + string[] sections = + { + "## 1. The per-frame input record", + "## 2. Jitter", + "## 3. Resources", + "## 4. The resolve's own inputs", + "## 5. Reset", + "## 6. Per-class motion status", + "## 7. Adapters", + "## 8. Reserved for the vendor plan", + }; + foreach (string section in sections) + { + Assert.True(doc.Contains(section, StringComparison.Ordinal), + $"{Doc} lost the section \"{section}\"."); + } + + // The reservations are the boundary of the contract; losing one would let + // a vendor integration assume the engine owns something it does not. + foreach (string reserved in new[] + { + "Native handles", "Extension negotiation", "Presentation lifetime", + "Ray-reconstruction guides", + }) + { + Assert.True(doc.Contains(reserved, StringComparison.Ordinal), + $"{Doc} section 8 no longer reserves \"{reserved}\" for the vendor plan."); + } + } + + /// + /// The plan points at the contract, and says which version. Without this the + /// document is just another file in docs/. + /// + [Fact] + public void ThePlanPointsAtTheContract() + { + string plan = Read("TAA-PLAN.md"); + + Assert.True(plan.Contains("docs/temporal-frame-contract.md", StringComparison.Ordinal), + $"TAA-PLAN.md P6 no longer points at {Doc}."); + Assert.True(plan.Contains("**Contract**", StringComparison.Ordinal), + "TAA-PLAN.md P6 no longer carries the Contract section pointer."); + Assert.True(plan.Contains(ContractVersion, StringComparison.Ordinal), + $"TAA-PLAN.md P6 no longer names the frozen contract version ({ContractVersion})."); + } + + // --------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------- + + private static void AssertSurface(Type type, string[] expected, string listName) + { + string[] actual = Surface(type); + AssertSetsMatch(expected, actual, type.Name, listName); + } + + private static void AssertSetsMatch(string[] expected, string[] actual, string typeName, string listName) + { + string[] added = actual.Except(expected, StringComparer.Ordinal).ToArray(); + string[] removed = expected.Except(actual, StringComparer.Ordinal).ToArray(); + + if (added.Length == 0 && removed.Length == 0) return; + + string message = + $"The public surface of {typeName} changed. It is frozen by {Doc} (contract {ContractVersion}).\n" + + "Update the document, bump its version, and replace " + + $"{listName} in Optimum.Tests/temporal-contract-tests.cs with the actual list below.\n" + + (added.Length > 0 ? "added:\n " + string.Join("\n ", added) + "\n" : "") + + (removed.Length > 0 ? "removed:\n " + string.Join("\n ", removed) + "\n" : "") + + "actual:\n" + + string.Join("\n", actual.Select(entry => " \"" + entry + "\",")); + + Assert.Fail(message); + } + + /// + /// The public surface of a type as stable strings: every declared public + /// member with its kind, its type and its parameter types, ordinal-sorted. + /// Parameter names are deliberately excluded - renaming a parameter is not a + /// contract change, adding one is. + /// + private static string[] Surface(Type type) + { + const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance + | BindingFlags.Static | BindingFlags.DeclaredOnly; + + List entries = new(); + + foreach (PropertyInfo property in type.GetProperties(flags)) + { + string accessors = (property.GetMethod is { IsPublic: true } ? " get" : "") + + (property.SetMethod is { IsPublic: true } ? " set" : ""); + entries.Add($"property {Name(property.PropertyType)} {property.Name}{accessors}"); + } + + foreach (FieldInfo field in type.GetFields(flags)) + { + entries.Add($"field {Name(field.FieldType)} {field.Name}"); + } + + foreach (MethodInfo method in type.GetMethods(flags)) + { + entries.Add($"method {Name(method.ReturnType)} {method.Name}(" + + string.Join(", ", method.GetParameters().Select(p => Name(p.ParameterType))) + ")"); + } + + foreach (ConstructorInfo constructor in type.GetConstructors(flags)) + { + entries.Add("ctor .ctor(" + + string.Join(", ", constructor.GetParameters().Select(p => Name(p.ParameterType))) + ")"); + } + + entries.Sort(StringComparer.Ordinal); + return entries.ToArray(); + } + + private static string Name(Type type) => type.Name; + + private static string ReadDoc() => Read(Doc); + + private static string Read(string relativePath) + => File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + + /// + /// A column-major perspective matrix exactly as Mat4d.Perspective builds one + /// (clip.w = -z_view), so the shear check is against the real convention. + /// + 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; + } + + 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; + + return ((ndcX * 0.5 + 0.5) * width, (ndcY * 0.5 + 0.5) * height); + } +} 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-frame-tests.cs b/Optimum.Tests/temporal-frame-tests.cs new file mode 100644 index 00000000..6aee8324 --- /dev/null +++ b/Optimum.Tests/temporal-frame-tests.cs @@ -0,0 +1,523 @@ +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 + }; + } + + /// + /// 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, uniforms); + frame.CaptureCameraPosition(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, 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] + 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; + } + + [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/Optimum.Tests/temporal-render-inventory-tests.cs b/Optimum.Tests/temporal-render-inventory-tests.cs new file mode 100644 index 00000000..d2992360 --- /dev/null +++ b/Optimum.Tests/temporal-render-inventory-tests.cs @@ -0,0 +1,221 @@ +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"); + // 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] + 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); + } + + /// + /// 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("public override bool RenderOptimumSkyMotion()", platform); + Assert.Contains("Platform.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() + { + // 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.", 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) + { + return File.ReadAllText(PatchReader.FindRepositoryFile(relativePath)); + } +} diff --git a/Optimum.Tests/transient-allocator-coverage-tests.cs b/Optimum.Tests/transient-allocator-coverage-tests.cs new file mode 100644 index 00000000..a6fe72d1 --- /dev/null +++ b/Optimum.Tests/transient-allocator-coverage-tests.cs @@ -0,0 +1,96 @@ +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); + // 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("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); + + 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/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/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/Optimum.Tests/vulkan-backend-integration-tests.cs b/Optimum.Tests/vulkan-backend-integration-tests.cs new file mode 100644 index 00000000..43e4ce8a --- /dev/null +++ b/Optimum.Tests/vulkan-backend-integration-tests.cs @@ -0,0 +1,605 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +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("clientPlatformWindows.InitializeGraphics(", 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 graphics are initialized"); + } + + /// + /// 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("clientPlatformWindows.InitializeGraphics(", 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); + // 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); + } + + /// + /// 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: 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 for a representative spread of the routed methods: the vanilla GL call is + /// 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("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(vulkanCall, VulkanPlatformSource.Read()); + Assert.Contains(vanillaCall, VulkanPlatformSource.ReadClientPlatformWindows()); + } + + /// + /// Binding a texture is three separate operations in GL - aim the sampler at + /// 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. + /// + [Fact] + public void TextureBindingAimsTheSamplerAndClearsAnyStaleOverride() + { + // Phase 1A step 4: the device body is VulkanClientPlatform.BindProgramTexture2D. + string added = VulkanPlatformSource.Read(); + + Assert.Contains("device.SetSamplerUnit(program.ProgramId, samplerName, textureNumber)", added); + Assert.Contains("stated.BindTexture(textureNumber, textureId)", added); + Assert.Contains("stated.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() + { + // Phase 1A step 4: VulkanClientPlatform.CompileShader / CreateShaderProgram. + string added = VulkanPlatformSource.Read(); + + 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); + } + + /// + /// 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) + { + // 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})"); + } + + /// + /// 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 = VulkanPlatformSource.Read(); + + 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 = VulkanPlatformSource.Read(); + + Assert.Contains("bool setupSsao = ClientSettings.SSAOQuality > 0;", added); + Assert.Contains("int primaryAttachments = (setupSsao ? 4 : 2);", added); + Assert.Contains("StateDrawBuffers(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 = VulkanPlatformSource.Read(); + + Assert.Contains("new Random(5)", added); + + // 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); + } + + /// + /// 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 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); + + 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) => + string.Join('\n', patch + .Split('\n') + .Where(line => line.StartsWith('+') && !line.StartsWith("+++"))); + + 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 ThePresentPathSplitsTheSubmissionAndRecreatesWithoutWaiting() + { + string ring = Read("Optimum.Render.Vulkan/Core/FrameRing.cs"); + Assert.DoesNotContain("PipelineStageFlags.AllCommandsBit", ring); + Assert.Contains("PresentWaitStages.RequireAcquireStage(acquireStage);", ring); + Assert.Contains("waitStages[waitCount] = PresentWaitStages.FrameWait;", ring); + + 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); + // 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"); + 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); + // 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); + } + + /// + /// Phase 1B step 1: timeline semaphores are the frame clock. The ring paces on + /// the Frame timeline and never on a fence, every frame submit signals it, and + /// deferred destruction is keyed on recorded timeline values. + /// + [Fact] + public void TheFrameRingPacesOnTheFrameTimelineAndRetiresOnTimelineValues() + { + string timeline = Read("Optimum.Render.Vulkan/Frame/FrameTimeline.cs"); + Assert.Contains("SemaphoreType = SemaphoreType.Timeline", timeline); + Assert.Contains("WaitSemaphores(", timeline); + Assert.Contains("GetSemaphoreCounterValue(", timeline); + + string retire = Read("Optimum.Render.Vulkan/Frame/RetireQueue.cs"); + Assert.Contains("entry.Frame <= frameCompleted && entry.Transfer <= transferCompleted", retire); + + string ring = Read("Optimum.Render.Vulkan/Core/FrameRing.cs"); + Assert.DoesNotContain("WaitForFences", ring); + Assert.DoesNotContain("CreateFence", ring); + Assert.DoesNotContain("ConcurrentQueue", ring); + Assert.Contains("StructureType.TimelineSemaphoreSubmitInfo", ring); + Assert.Contains("signals[signalCount] = _timeline.Frame;", ring); + Assert.Contains("_timeline.NoteFrameSubmitted(FrameValue);", ring); + + // Every deferred destroy in the renderer goes through the ring's retire queue. + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.DoesNotContain("_frames.Current.DeferDeletion(", device); + Assert.Contains("ring.DeferDeletion(texture)", Read("Optimum.Render.Vulkan/Core/TextureManager.cs")); + Assert.Contains("ring.DeferDeletion(mesh)", Read("Optimum.Render.Vulkan/Core/MeshManager.cs")); + } + + /// + /// Phase 1B step 2: readbacks submit the frame's recorded part and continue in + /// the same slot, waiting only on their own timeline value; occlusion queries + /// read results from a per-slot ring without ever waiting; FlushFrame is gone. + /// + [Fact] + public void ReadbacksAndOcclusionQueriesNeverFlushTheFrameOrWaitForTheDevice() + { + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.DoesNotContain("FlushFrame", device); + Assert.DoesNotContain("Thread.Yield", 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); + + string ring = Read("Optimum.Render.Vulkan/Core/FrameRing.cs"); + Assert.Contains("public ulong SubmitPartial()", ring); + Assert.Contains("_timeline.WaitForFrame(slot.LastSignalledValue, WaitSite.FramePacing);", ring); + Assert.Contains("LastSignalledValue = FrameValue;", ring); + + string queries = Read("Optimum.Render.Vulkan/Frame/QueryRing.cs"); + Assert.Contains("CmdResetQueryPool(", queries); + Assert.Contains("QueryResultFlags.ResultWithAvailabilityBit", queries); + Assert.Contains("if (_clock.FrameCompleted < record.FrameValue) return;", queries); + 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); + } + + /// + /// 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() + { + 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); + Assert.Contains("StructureType.LayerSettingsCreateInfoExt", context); + Assert.Contains("\"validate_best_practices_nvidia\"", 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 mesh = Read("Optimum.Render.Vulkan/VulkanDevice.NativeMesh.cs"); + Assert.Contains("if (instanceCount <= 0) return false;", mesh); + } + + /// + /// 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.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] + public void TheBootstrapAlwaysLogsWhichRendererItChose() + { + string program = Read("patches/VintagestoryLib/Vintagestory.Client/ClientProgram.cs.patch"); + Assert.Contains("\"[Optimum] OpenGL renderer: selected by config\"", program); + Assert.Contains("(OptimumRenderBootstrap.Advisory ?? \"selected by config\")", program); + Assert.Contains("\"[Optimum] OpenGL renderer: \" + optimumRendererReason", program); + } +} 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)); +} diff --git a/Optimum.Tests/vulkan-test-validation-coverage-tests.cs b/Optimum.Tests/vulkan-test-validation-coverage-tests.cs new file mode 100644 index 00000000..c309c2f0 --- /dev/null +++ b/Optimum.Tests/vulkan-test-validation-coverage-tests.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Optimum.Tests; + +/// +/// The GPU suite runs with synchronization and best-practices validation by +/// default, and only because every context and device comes from one helper. +/// A test that builds its own options silently drops back to plain validation, +/// which is how the P2 and P4 hazards went unseen; these checks keep the +/// helper the only way in. +/// +public class VulkanTestValidationCoverageTests +{ + private const string TestProject = "Optimum.Render.Vulkan.Tests"; + + [Fact] + public void OnlyTheSharedHelperBuildsContextOptionsOrDevices() + { + foreach (string file in TestSources()) + { + string name = Path.GetFileName(file); + if (name == "GpuTest.cs") continue; + string? bypass = HelperBypass(File.ReadAllText(file)); + Assert.True(bypass == null, name + " " + bypass); + } + } + + /// + /// The ways a test file can get a context or device past the helper, including + /// the target-typed new() the plain "new VulkanDevice" check missed, and + /// switching the helper's validation off after the fact. + /// + internal static string? HelperBypass(string source) + { + if (source.Contains("new VulkanContextOptions", StringComparison.Ordinal) || + Regex.IsMatch(source, @"\bVulkanContextOptions\??\s+\w+\s*=\s*new\s*\(")) + return "builds its own VulkanContextOptions; use GpuTest.ContextOptions"; + if (source.Contains("new VulkanDevice", StringComparison.Ordinal) || + Regex.IsMatch(source, @"\bVulkanDevice\??\s+\w+\s*=\s*new\s*\(")) + return "creates its own VulkanDevice; use GpuTest.NewDevice or GpuTest.TryCreateDevice"; + if (Regex.IsMatch(source, @"\bEnableValidation\s*=\s*false\b")) + return "turns validation off; the suite runs every context validated"; + return null; + } + + [Theory] + [InlineData("var options = new VulkanContextOptions { Headless = true };", true)] + [InlineData("VulkanContextOptions options = new() { Headless = true };", true)] + [InlineData("VulkanDevice device = new();", true)] + [InlineData("VulkanDevice? device = new() { DebugMode = true };", true)] + [InlineData("var device = new VulkanDevice();", true)] + [InlineData("options.EnableValidation = false;", true)] + [InlineData("VulkanContextOptions options = GpuTest.ContextOptions(messages);", false)] + [InlineData("Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), \"No GPU\");", false)] + public void TheBypassCheckCatchesTargetTypedConstruction(string source, bool bypass) + { + Assert.Equal(bypass, HelperBypass(source) != null); + } + + [Fact] + public void EveryNoErrorsIsPairedWithNoSyncHazards() + { + foreach (string file in TestSources()) + { + string name = Path.GetFileName(file); + if (name.StartsWith("ValidationAssert", StringComparison.Ordinal)) continue; + string source = File.ReadAllText(file); + Assert.True( + Count(source, "ValidationAssert.NoErrors(") == Count(source, "ValidationAssert.NoSyncHazards("), + name + ": every ValidationAssert.NoErrors needs a ValidationAssert.NoSyncHazards"); + } + } + + [Fact] + public void TheHelperDefaultsToSyncAndBestWithAnEnvironmentOverride() + { + string helper = Read(TestProject + "/GpuTest.cs"); + Assert.Contains("\"OPTIMUM_TEST_VALIDATION_FEATURES\"", helper); + Assert.Contains("DefaultValidationFeatures = \"sync,best\"", helper); + Assert.Contains("EnableValidation = true", helper); + + string device = Read("Optimum.Render.Vulkan/VulkanDevice.cs"); + Assert.Contains("ConfigureContextOptions?.Invoke(options);", device); + } + + [Fact] + public void PoisonModeIsReadAtContextCreationAndAppliedAtTheCreationSites() + { + string context = Read("Optimum.Render.Vulkan/Core/VulkanContext.cs"); + Assert.Contains("\"OPTIMUM_VULKAN_POISON\"", context); + Assert.Contains("PoisonRequested(Environment.GetEnvironmentVariable(PoisonVariable))", context); + + string textures = Read("Optimum.Render.Vulkan/Core/TextureManager.cs"); + Assert.Contains("if (_context.PoisonFreshResources) Poison(texture);", textures); + + string resources = Read("Optimum.Render.Vulkan/Core/VulkanResources.cs"); + Assert.Contains("VulkanPoison.FillHostMemory(Mapped, size);", resources); + } + + private static string[] TestSources() => + Directory.GetFiles(Path.GetDirectoryName(PatchReader.FindRepositoryFile(TestProject + "/GpuTest.cs"))!, "*.cs"); + + 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/TAA-PLAN.md b/TAA-PLAN.md new file mode 100644 index 00000000..560e2c08 --- /dev/null +++ b/TAA-PLAN.md @@ -0,0 +1,858 @@ +# TAA for Optimum: plan (revised after 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 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) + +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` + = **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 + 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, 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 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 | 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, 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) | +| 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. +- 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. + +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 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 + 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. + +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 + 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, and cloud pixels are UNSUPPORTED for external motion +consumers.** `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 (`taaCloudReactive` discards the history there) but it is wrong data for the later +consumers the plan is built for - FSR/XeSS reactive+mv, and frame generation especially. Contract +term, recorded in `docs/temporal-frame-contract.md` section 6.1: **downstream consumers must reject +cloud pixels through the reactive mask (`motion.b`) and must not treat their `rg` as motion.** 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 - future work, not a v1 guarantee. + +(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. + +P4 status, movers (the P3 carry-over) (2026-09-10, verification updated 2026-09-11): landed on +`feat/taa`. **No phase of P4 itself ran `make deploy` or the client**, and the per-class mover +behaviour in the table below is still **unverified in game** - nobody has looked at a helve hammer +or a firepit's contents on either backend. What has since been verified in game, on the later +build that contains this code: the per-renderer entity motion-writer gate, whose lazily created +type cache threw a `NullReferenceException` on the first entity frame and was fixed and confirmed +on Vulkan and OpenGL (2830577), and the P5 run (7b0168d, deployed c9758ce+5b952da) in which both +backends start, log their renderer, load the temporal stages and produce clean frames with no +exceptions and no Vulkan synchronization/best-practices hazards. That is a smoke pass over the +pipeline, not the acceptance matrix: the 18 rows of `docs/taa-acceptance.md` have not been run. +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) covered eight more methods, and P5 closed it.** `mod-patcher` `Methods` +entries were added for every mover in P4 while `patches/runtime/**` still had no donor for any of +them, so the installed runtime kept the vanilla bodies and every one of these renderers ghosted +there while the build tree was correct. **Closed in P5** (c897e23, merged as 0120422): donors for +all eight movers now live under `patches/runtime/VSSurvivalMod/Vintagestory/GameContent/` +(`HelveHammerRenderer`, `FruitpressContentsRenderer`, `ResonatorRenderer`, +`BloomeryContentsRenderer`, `ForgeContentsRenderer`, `FirepitContentsRenderer`, +`PotInFirepitRenderer`, and `MechNetworkRenderer` under `.../GameContent/Mechanics/`), with +`check-patches.sh` reporting 43 runtime patches applied and exact donors compiled. +`Optimum.Tests/taa-runtime-donor-coverage-tests.cs` and +`Optimum.Tests/mod-patcher-manifest-consistency-tests.cs` guard them from regressing. + +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). **P4 itself ran neither `make deploy` nor the client**, so by rule 3 none +of its per-class visual claims is done; the 18-row acceptance matrix in `docs/taa-acceptance.md` +is still unrun. Verified in game since, on later builds carrying this code: the entity +motion-writer gate on both backends (2830577) and the P5 smoke run (7b0168d, deployed +c9758ce+5b952da) - both backends start, log their renderer, render the temporal stages without +exceptions, Vulkan clean under synchronization + best-practices validation. GL branches added in +this phase are therefore no longer wholly unexecuted (the OpenGL P5 run drove the frame), but they +still have **no GPU proof**: `Optimum.Render.Vulkan.Tests` remains the only GPU harness, so every +GL branch is unasserted. + +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. + +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`; + 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. + +P5 status (2026-09-11): landed on `feat/taa` (13b4cd0 sharpen + mip bias, cd72089 settings +rows / scanner rules / packaging, c897e23 runtime donors, 8a3c33f acceptance and performance +harness, f1a6300 integrate, 77f0c6d adversarial-review fixes). **The acceptance matrix has not +been run** - no phase of P5 ran `make deploy` or the client, so by rule 3 P5 is not done, and +the default-on decision is not takeable yet. Everything below is what the code now does and +what the tests prove about it, which is a different claim from "it looks right". + +What P5 built: + +- **Sharpen**. `taa-sharpen.vsh/.fsh`, an RCAS variant with the lobe strength as a uniform + instead of the baked `exp2(-0.2)`, running on the resolved RGBA16F colour into its own + render-resolution target (frame buffer slot 21), placed immediately after the resolve so + bloom, god rays and the Luma copy Final reads all see the same image. + `TaaSharpness <= 0` is a true bypass - the shader returns the centre texel before the first + ring tap, and the pass does not run at all - and the HDR upper clamp is dropped (RCAS's own + lobe already passes anything above 1 through unsharpened). A target that fails to allocate + costs the sharpening only, never TAA. +- **No double sharpening**. `OptimumFsrBlitActive()` is one shared condition asked by both + `RenderOptimumTaaSharpen` and `BlitPrimaryToDefault`: below render scale 1 the blit finishes + the frame with FSR's own RCAS at native resolution, so the TAA sharpen skips itself entirely. +- **Mip bias**. `OptimumConfig.EffectiveTerrainLodBias` = the render scale's `log2` term (as + before) plus `TaaMipBias` while `EffectiveTaa`, clamped. Applied on both backends at both + call sites: the atlas texture parameter in `ChunkRenderer` and the chunkopaque/chunktopsoil + sampler objects in `ShaderRegistry`. Zero total makes no call at all. +- **Settings**. Three rows in the Optimum tab (`optTaa`, `optTaaSharpness`, `optTaaMipBias`) + with lang strings and hover texts, persisted in `optimum.json` and clamped on load. The + toggle rebuilds the frame buffers, reloads the shaders and raises + `EnumTemporalResetReason.Toggle`; both sliders apply live. The toggle refuses on + `IsFeatureExplicitlyDisabled("Taa")`, never on `IsShaderFeatureDisabled`, so a missing + launcher scan cannot veto TAA. +- **Scanner**. TAA is vetoed by an external copy of any stage it owns - the `taa-` prefix + (so a stage added later is covered without editing the decision), the liquid velocity pass, + the FSR pair the sharpen shares its maths with, and **any** file in a `shaderincludes/` + directory, because `ShaderRegistry` merges them all into the one dictionary every motion + writer compiles against. +- **Packaging**. `make deploy` and all five `scripts/package*` copy the two asset directories + by wildcard and then **verify every source file arrived**, failing the deploy or the package + instead of shipping vanilla's shader under Optimum's name; `package.ps1`'s required-file list + names the TAA stages one by one so a reviewer can read what a release contains. +- **Installed-runtime donors**. P3 finding (f) and P4 finding (t) are closed: 23 + `patches/runtime/**` donors now carry the movers, `check-patches.sh` reports 43 runtime + patches applied with exact donors compiled, and two test families keep it that way - + `TaaRuntimeDonorCoverageTests` (marker parity fork patch vs donor patch, plus "every + instrumented fork patch is mapped") and `ModPatcherManifestConsistencyTests. + EveryTransplantedMethodHasARuntimeDonor` (every `Methods` entry's declaring type has a donor + or an Optimum-authored overlay, with the two FluffyClouds gaps listed explicitly). +- **Harness**. `docs/taa-acceptance.md` is the runnable matrix (18 rows plus performance and + memory, tooling, preconditions, and the decision gate); `scripts/dev/perf-capture.sh` drives + a launch-warmup-measure-close cycle; `scripts/dev/luma-diff.py` is the still-frame luminance + measurement; `ClientMain.OptimumLogFrameTime` writes a per-second frame-time line, inert + unless `OPTIMUM_FPS_LOG` names a file, so the numbers are backend-neutral. + +Exact vs fallback, updated for P5 (the P4 table is otherwise unchanged): + +| Class | Vector | Reactive | Change in P5 | +|---|---|---|---| +| Resolved colour, post-resolve | - | - | new: optional RCAS sharpen at `TaaSharpness`, bypassed at 0 and skipped whole when FSR's RCAS will run at native resolution | +| chunkopaque, chunktopsoil | exact | 0 | unchanged vectors; mip selection now carries `TaaMipBias` through the sampler objects, live | +| Every other terrain pass (liquid, transparent, shadow) | as P4 | as P4 | mip selection now carries the same bias through the atlas texture parameter, live and in step with the samplers | +| Liquid surfaces | exact | **0.3, still a compile-time constant** | `taaLiquidReactive` was on P4's "still owed" list to become a setting in P5; it did not | +| Volumetric clouds | camera-rotation-only | **`mix(coverage, 1, coverage)`, still a compile-time constant** | same: `taaCloudReactive` is still `ClientPlatformWindows.OptimumCloudReactive = 1f` | +| Helve hammer, resonator, fruitpress, pot lid, bloomery/forge/firepit contents, falling blocks, FP hands, echo chamber, held/dropped items, quern, every mech renderer | exact | 0 | **now exact on the installed-launcher path too**, not only in a from-source build: `patches/runtime/**` donors exist for all of them | +| Forge/anvil work items, static standard-shader users, mod geometry | fallback | 0 | unchanged | + +Findings to carry: + +(ab) **A sampler object hides a texture parameter, and the setting was on the wrong side of +it.** `chunkopaque`/`chunktopsoil` sample the atlas through sampler objects, and a bound +sampler object overrides the texture object's state on that unit for everything it carries, +LOD bias included. The bias was written into those samplers once per shader load and into the +atlas texture every frame, so dragging the mip-bias slider moved liquid, transparent and +shadow terrain and left opaque terrain and topsoil on the bias they were compiled with - two +mip selections of the same atlas in one frame, while the tooltip said "applies immediately". +`ShaderRegistry.ApplyOptimumTerrainSamplerLodBias` is now the single writer and +`ChunkRenderer.SetOptimumTextureLodBias` calls it, so both halves move together or neither +does. The backend half is not free either: the live change only reaches the GPU because +`VulkanDevice` resolves the unit's sampler at draw time and the descriptor set is keyed on the +resolved `VkSampler`, not on the sampler id - pinned by +`VulkanDeviceIntegrationTests.ALodBiasWrittenToAnAlreadyBoundSamplerChangesTheMipTheGpuReads`. + +(ac) **"No call at all" needs a cache value that means "never".** +`ChunkRenderer.optimumTextureLodBias` started at `0f`, so the first frame of a TAA-off +native-scale session saw "0 wanted, cache not NaN" and wrote an explicit LOD bias of 0 over +the driver default on every atlas - the one call that configuration is documented never to +make, and with (ab) fixed it would have reached every terrain sampler too. `float.NaN` is the +only initialiser that says "Optimum has never touched this". + +(ad) **The translation gate picked the new stage up for free, and that is worth knowing.** +`taa-sharpen` needed no corpus row: `ShaderCorpus.LoadShaderFiles` overlays `sources/shaders` +and `ProgramNames` takes every base name with both stages, so +`EveryVanillaProgramTranslatesToSpirv` translates it in every variant. That is only true for a +stage that is a plain `.vsh`/`.fsh` pair under `sources/shaders`; the P3/P4 writers needed +explicit rows precisely because they live inside define combinations no row produced +(findings (d) and (v)). + +(ae) **The sharpen target is allocated whenever TAA is on, including at render scale below 1 +where the pass can never run** - 16 MiB at 1080p, ~64 MiB at 4K, on the handheld this plan +targets. Deliberately left: gating it on the render scale would put a second copy of the +"is FSR going to run" condition next to the shared `OptimumFsrBlitActive()` the phase +introduced to stop exactly that drift. Revisit with the memory numbers from the matrix. + +(af) **Finding (z) is unpaid.** `RenderOptimumTaaResolve` and `RenderOptimumSkyMotion` still +allocate five small arrays each per frame; P4 said "fold both into fields when P5 measures", +and P5 did not measure. + +Still owed for P5 (rule 3), in the game, on both backends, with the renderer confirmed from +the log - all of it is `docs/taa-acceptance.md`: +- the 18 acceptance rows (A1-A18), each twice per backend, TAA on and off, with the + seven-pair luminance medians recorded; A18 is the TAA-off byte-identity check, which so far + exists only as a code argument and a coverage test, never as a measured frame; +- everything P4 left owed and P5 did not close: the liquid velocity pass's depth write against + block outlines, SSAO near water and rifts; whether reactive 1 on cloud-covered sky costs the + sky's own AA and whether `mix(coverage, 1, coverage)` is the right curve; the near-decal + ghosting finding (o) identified; the movers through their animations; faint cube particles; + and the vertex-warp cost of evaluating the warp twice (finding (j), still unpaid); +- the GL path of everything P3/P4/P5 added: `Optimum.Render.Vulkan.Tests` is still the only + GPU harness, so every GL branch - the sky pass's depth-func dance, + `BeginMotionOnlyWrite`'s `GL_NONE` array, `glBlendFunci`, and now the sharpen pass and the + `GL.SamplerParameter` half of the live mip bias - has never executed; +- performance on the Arc 140V: frame delta, GPU pass timestamps, CPU frame time, 1% lows, with + renderer name, power mode and thermals; and the measured memory against the plan's estimate + (motion 15.8 + two colour histories 31.6 + aux 7.9 + prev-depth 15.8 MiB, plus the sharpen + target's 15.8 MiB, which the plan's figure does not include); +- `taaLiquidReactive` and `taaCloudReactive` as settings rather than constants, which P4 + assigned to P5 and P5 did not do. They are uniforms already, so this is a config field and a + row each, not a shader change - but they should be tuned by measurement in the matrix first, + which is why leaving them until the matrix runs is defensible. + +**Default-on is not decided.** The plan says decide only after the matrix passes, and the +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): 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): + +| backend | TAA | mean ms | 1% low ms | frames/30 s | +|---|---|---|---|---| +| Vulkan | on | 6.87 | 15.8 | 4397 | +| Vulkan | off | 6.09 | 12.0 | 4941 | +| OpenGL | on | 6.06 | 8.1 | 4963 | +| OpenGL | off | 6.07 | 10.0 | 4962 | + +Caveat: the Wayland compositor caps presentation at the 165 Hz refresh even with `vsyncMode 0` +(`--vsync off` added to the script), so every row except Vulkan+TAA sits on the cap; the only +cost visible is Vulkan TAA >= 0.8 ms at half render resolution. A real cost number needs GPU +timestamps or an uncapped surface; the Arc 140V run in the plan's P5 matrix remains the target +measurement. The 18-row acceptance matrix (docs/taa-acceptance.md) and the default-on decision are +the user's; TAA stays default-off until then. + +**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. + +**Contract** (2026-09-11): frozen as **v1** in **`docs/temporal-frame-contract.md`**. That document, +not this plan, is what every temporal consumer is written against - the in-house resolve today, +FSR 3.1 / XeSS 2 / DLSS next, frame generation and ray reconstruction after that. It specifies the +per-frame input record member by member (type, units, coordinate convention, the point in the frame +after which each value is this frame's), every resource with its format, resolution, sampler state +and channel semantics (the motion attachment's `rg`/`b`/`a` including the writer-depth validity +tolerance, the history colour/glow/linear-depth slots, the sharpen target), the jitter definition +and sequence, the reset reasons and their triggers, the per-class exact/fallback/reactive status, +and the adapter formulas for FSR 3.1, XeSS 2 and DLSS. Native handles, extension negotiation, +presentation lifetime and ray-reconstruction guides are explicitly reserved for the vendor plan. + +`Optimum.Tests/temporal-contract-tests.cs` is the stability test: it pins the public surface of +`IOptimumTemporalContext` and `OptimumTemporalFrame` against a checked-in list, and pins the +conventions the document states - the shear formula, the motion-vector scale and sign per adapter, +the writer-depth tolerance expression in `taa-resolve.fsh`, the history slot indices and the +attachment formats in `ClientPlatformWindows` - so a change to any of them fails a test that names +the document. Changing the contract means changing the code, the document, its version, that list, +and this section, in that order. + +## 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 + +## 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 +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. + +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/VULKAN-BACKEND-PLAN.md b/VULKAN-BACKEND-PLAN.md new file mode 100644 index 00000000..188a60ca --- /dev/null +++ b/VULKAN-BACKEND-PLAN.md @@ -0,0 +1,1550 @@ +# 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`, `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: + +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` | +| 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` | +| 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 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 + +`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 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. + +## 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..e93a6d69 100644 --- a/VintageStory.slnx +++ b/VintageStory.slnx @@ -18,9 +18,21 @@ + + + + + + + + + + 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..09adad78 --- /dev/null +++ b/tools/InteropProbe/Program.cs @@ -0,0 +1,278 @@ +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"); + + bool wglInteropEntryPointsOk = true; + foreach (string name in new[] + { + "wglDXOpenDeviceNV", + "wglDXRegisterObjectNV", + "wglDXLockObjectsNV", + "wglDXUnlockObjectsNV", + }) + { + bool valid = IsValidProc(WglGetProcAddress(name)); + wglInteropEntryPointsOk &= valid; + Report(name + " (entry point)", valid, 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 && wglInteropEntryPointsOk) + { + 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 (!IsValidProc(address)) + { + address = WglGetProcAddress("wglGetExtensionsStringEXT"); + if (!IsValidProc(address)) + { + 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(); + + /// + /// 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; + } +} diff --git a/tools/shader-compiler/Optimum.Shaders.Compiler.csproj b/tools/shader-compiler/Optimum.Shaders.Compiler.csproj new file mode 100644 index 00000000..4a841841 --- /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 + $(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)shaders-vk.inputs + $(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)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); +}