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