diff --git a/.gitattributes b/.gitattributes
index 4f2a87cb..2c87bb6f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -12,3 +12,9 @@
*.cmd text eol=lf
*.slnx text eol=lf
*.list text eol=lf
+*.glsl text eol=lf
+*.vert text eol=lf
+*.frag text eol=lf
+*.comp text eol=lf
+*.vsh text eol=lf
+*.fsh text eol=lf
diff --git a/.gitignore b/.gitignore
index 848b2cb9..98e8d3d0 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/Temporal/AcceptanceTests.cs) reads, so it has to be tracked.
+!docs/vulkan.md
+# ...and the frozen temporal frame contract, for the same reason: the P6 stability
+# test (Optimum.Tests/Temporal/ContractTests.cs) reads it.
+!docs/vulkan.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.md
+!docs/vulkan.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.md
+# ...and the native render systems design (Phase 3b) the system stages follow.
+!docs/vulkan.md
+# ...and the modder documentation for Vulkan-native mod support (passes, motion writers, shaders).
+!docs/vulkan.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..04096e1b 100644
--- a/Makefile
+++ b/Makefile
@@ -37,9 +37,9 @@ BOOTSTRAP_ARGS :=
ifneq ($(CLIENT_ARCHIVE),)
BOOTSTRAP_ARGS += --client-archive $(CLIENT_ARCHIVE)
endif
-BOOTSTRAP_ARGS := --version $(VERSION)
+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.md), 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..03cffc02 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, "*.glsl", SearchOption.TopDirectoryOnly)
+ .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 a126f9e0..b0a0d550 100644
--- a/Optimum.Patcher/Program.cs
+++ b/Optimum.Patcher/Program.cs
@@ -54,6 +54,7 @@
"Optimum.EntityLightBatchBuffer",
"Optimum.OptimumOptiTimeGuard",
"Vintagestory.Client.NoObf.OptimumGreedyMeshEmitter",
+ "Vintagestory.Client.NoObf.OptimumAoClass",
// Server-side worldgen scheduler + chunk read pool (see
// docs/implementation-plans/server-worldgen-chunk-pool-cecil-wiring-plan-2026-08-11.md):
// parallel SQLite read pool used by ChunkServerThread/ServerSystemSupplyChunks.
@@ -63,6 +64,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 +235,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 +264,9 @@
"PrepareOptimumEntityLights",
"BeginOptimumEntityShaderSegment",
"EndOptimumEntityShaderSegment",
+ // TAA review fix: per-renderer motion-window gate.
+ "optimumMotionWriterTypes",
+ "OptimumIsMotionWriter",
},
["Vintagestory.Client.NoObf.ClientChunk"] = new()
{
@@ -121,6 +288,14 @@
},
["Vintagestory.Client.NoObf.ClientPlatformWindows"] = new()
{
+ "OptimumPostAmbientOcclusionTexture",
+ "OptimumPostSsaaLevel",
+ "OptimumPostSsaoInScene",
+ "OptimumTaaResolveDraw",
+ "OptimumTaaSharpenDraw",
+ "_optimumSharedIndirectCommands",
+ "_optimumSingleIndirectBufferCapacity",
+ "_optimumSingleIndirectBufferId",
"_optimumSettingsInitialized",
"EnsureOptimumDefaults",
"_optimumTimerResolutionRaised",
@@ -131,23 +306,227 @@
"_optimumFocusLostStopwatch",
"optimumFsrDisabled",
"DisableOptimumFsr",
- "_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.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 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.
+ "optimumAmbientOcclusionTexture",
+ "OptimumAoWorkingSlot",
+ "OptimumAoEdgesSlot",
+ "OptimumAoDepthSlot",
+ "OptimumAoOutputSlot",
+ "OptimumAoOutputCount",
+ // 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",
+ // 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",
},
@@ -155,6 +534,7 @@
["Vintagestory.Client.NoObf.GuiCompositeSettings"] = new()
{
"oButtonBounds",
+ "vButtonBounds",
"optimumContentBounds",
"optimumRowY",
"OnOptimumScrollChanged",
@@ -163,6 +543,7 @@
"AddOptimumSliderRow",
"AddOptimumDropdownRow",
"OnOptimumOptions",
+ "OnVulkanOptions",
"_AddOptimumTab",
"onOptimumBackgroundFpsChanged",
"onOptimumFramePacingChanged",
@@ -179,10 +560,16 @@
"onOptimumChiselLodDistChanged",
"onOptimumOcclusionScaleChanged",
"onOptimumDynLightCacheChanged",
+ "onOptimumRendererChanged",
"onOptimumEntityLightBatchChanged",
"onOptimumEntityShaderCacheChanged",
"onOptimumRenderScaleChanged",
"onOptimumGodRaysCapChanged",
+ "onOptimumTaaChanged",
+ "onOptimumTaaSharpnessChanged",
+ "onOptimumTaaMipBiasChanged",
+ "onOptimumAmbientOcclusionChanged",
+ "onOptimumAmbientOcclusionDebugChanged",
#if OPTIMUM_GREEDY_MESH
"onOptimumGreedyMeshChanged",
"onOptimumGreedySpanChanged",
@@ -228,6 +615,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
@@ -280,6 +673,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
@@ -430,6 +841,9 @@
// --- Phase 1: Method bodies to transplant ---
var targets = new List
{
+ new("Vintagestory.Client.NoObf.ClientEventManager", "TriggerRenderStage", 2, new[] { "Vintagestory.API.Client.EnumRenderStage", "System.Single" }),
+ new("Vintagestory.Client.NoObf.ClientPlatformWindows", "RenderMesh", 5, new[] { "Vintagestory.API.Client.MeshRef", "System.Int32[]", "System.Int32[]", "System.Int32", "System.Boolean" }),
+ new("Vintagestory.Client.NoObf.InventoryItemRenderer", "RenderItemstackToGui", 10, new[] { "Vintagestory.API.Common.ItemSlot", "System.Double", "System.Double", "System.Double", "System.Single", "System.Int32", "System.Single", "System.Boolean", "System.Boolean", "System.Boolean" }),
// Entity render distance cull + shadow cull (reads injected ClientSettings.Optimum* props)
new("Vintagestory.Client.NoObf.SystemRenderEntities", "OnRenderOpaque3D", 1),
new("Vintagestory.Client.NoObf.SystemRenderEntities", "OnBeforeRender", 1),
@@ -444,6 +858,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)
@@ -463,6 +906,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
@@ -482,22 +934,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),
- // 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" }),
- // Issue #74: item-render profiler summary hook at the final render stage, and
- // the reused-scratch per-slot item render path.
- new("Vintagestory.Client.NoObf.ClientEventManager", "TriggerRenderStage", 2,
- new[] { "Vintagestory.API.Client.EnumRenderStage", "System.Single" }),
- // Issue #74 item-render profiler: measure per-slot GetItemStackRenderInfo cost.
- new("Vintagestory.Client.NoObf.InventoryItemRenderer", "RenderItemstackToGui", 10,
- new[] { "Vintagestory.API.Common.ItemSlot", "System.Double", "System.Double", "System.Double", "System.Single", "System.Int32", "System.Single", "System.Boolean", "System.Boolean", "System.Boolean" }),
+ // 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),
// GuiCompositeMainMenuLeft: Optimum link in main menu (no lambdas)
new("Vintagestory.Client.GuiCompositeMainMenuLeft", "Compose", 0),
// E3: particle spawn distance gate, before the per-particle revive loop
@@ -539,6 +1106,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)
@@ -578,6 +1150,9 @@
new("Vintagestory.Client.NoObf.ChunkTesselator", "BuildBlockPolygons_EdgeOnly", 3),
new("Vintagestory.Client.NoObf.ChunkTesselator", "BuildDecorPolygons", 5),
new("Vintagestory.Client.NoObf.ChunkTesselator", "GetMeshPoolForPass", 3),
+ // GTAO thin class: stamp cross quads independently of snow and JSON faces.
+ new("Vintagestory.Client.NoObf.CrossTesselator", "DrawCross", 2),
+ new("Vintagestory.Client.NoObf.JsonTesselator", "AddJsonModelDataToMesh", 7),
new("Vintagestory.Client.NoObf.TesselatedChunkPart", "AddModelAndStoreLocation", 8),
// Eco Machina anchors its tapered-tree transpiler on this method's local slots.
new("Vintagestory.Client.NoObf.ChunkTesselator", "CalculateVisibleFaces", 4),
@@ -670,6 +1245,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,
@@ -707,7 +1304,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/AmbientOcclusion/SsaoTests.cs b/Optimum.Render.Vulkan.Tests/AmbientOcclusion/SsaoTests.cs
new file mode 100644
index 00000000..0d2f35ec
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/AmbientOcclusion/SsaoTests.cs
@@ -0,0 +1,609 @@
+// Source: Optimum.Render.Vulkan.Tests/SceneSsaoTests.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+using System;
+using System.Runtime.InteropServices;
+using Vintagestory.API.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+///
+/// The AO multiply the TAA path runs before the resolve: scene-ssao draws into Primary
+/// with only colour 0 selected and the Multiply blend, so the resolve accumulates the
+/// occlusion together with the scene. Every other Primary attachment and the depth have
+/// to come out untouched, frame after frame.
+///
+public class SceneSsaoTests(ITestOutputHelper output)
+{
+ [SkippableTheory]
+ [InlineData(1)]
+ [InlineData(2)]
+ public unsafe void OcclusionMultipliesOnlySceneColourBeforeTheResolve(int quality)
+ {
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device");
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ const int size = 8, frames = 8;
+ var files = ShaderCorpus.LoadShaderFiles();
+ int program = GpuTest.LinkProgram(
+ seam,
+ files["scene-ssao.vsh"],
+ files["scene-ssao.fsh"].Replace("#version 330 core", "#version 330 core\n#define SSAOLEVEL " + quality),
+ "scene-ssao" + quality);
+
+ // Rows alternate between two AO levels, so SSAOLEVEL 2's min with the row
+ // above is visible: every row sees the darker of the two.
+ var ao = new byte[size * size * 4];
+ for (int y = 0; y < size; y++)
+ for (int x = 0; x < size; x++)
+ for (int c = 0; c < 4; c++) ao[(y * size + x) * 4 + c] = (byte)(y % 2 == 0 ? 64 : 192);
+ int occlusion;
+ fixed (byte* data = ao) occlusion = seam.CreateTexture2DRaw(size, size, 0x8058, (IntPtr)data, 4);
+
+ var colors = new int[frames][];
+ var depths = new int[frames];
+ var targets = new int[frames];
+ for (int i = 0; i < frames; i++)
+ {
+ targets[i] = seam.CreateFramebuffer(size, size);
+ colors[i] = new int[5];
+ for (int slot = 0; slot < 5; slot++)
+ {
+ colors[i][slot] = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4);
+ seam.AttachTexture(targets[i], (EnumFramebufferAttachment)(36064 + slot), colors[i][slot], 0);
+ }
+ depths[i] = seam.CreateTexture2D(size, size,
+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false);
+ seam.AttachTexture(targets[i], EnumFramebufferAttachment.DepthAttachment, depths[i], 0);
+ }
+
+ seam.SetViewport(0, 0, size, size);
+ seam.SetCullFace(false);
+ seam.SetDepthTest(false);
+ seam.SetSamplerUnit(program, "ssaoScene", 0);
+ seam.SetUniform(program, seam.GetUniformLocation(program, "invRenderHeight"), 1f / size);
+ for (int i = 0; i < frames; i++)
+ {
+ seam.BeginFrame();
+ seam.BindFramebuffer(targets[i]);
+ seam.SetDrawBuffers(targets[i], 31);
+ seam.ClearColor(0, (i + 1) / 16f, (i + 1) / 16f, (i + 1) / 16f, 1);
+ for (int slot = 1; slot < 5; slot++) seam.ClearColor(slot, slot / 8f, slot / 8f, slot / 8f, 1);
+ seam.ClearDepth(0.375f);
+ seam.SetDrawBuffers(targets[i], 1);
+ seam.SetBlend(true, EnumBlendMode.Multiply);
+ seam.UseProgram(program);
+ seam.BindTexture(0, occlusion);
+ seam.DrawFullscreenTriangle();
+ seam.SetBlend(true, EnumBlendMode.Standard);
+ seam.SetDrawBuffers(targets[i], 31);
+ seam.Present();
+ }
+
+ // The sequence completes before any readback or CPU wait.
+ seam.BeginFrame();
+ for (int i = 0; i < frames; i++)
+ {
+ for (int slot = 0; slot < 5; slot++)
+ {
+ byte[] pixels = seam.ReadBackLevel0ForTests(colors[i][slot]);
+ for (int y = 1; y < size; y++)
+ for (int x = 0; x < size; x++)
+ {
+ double factor = quality == 2 || y % 2 == 0 ? 64 : 192;
+ double expected = slot == 0 ? Math.Round((i + 1) / 16.0 * 255) * factor / 255 : slot / 8.0 * 255;
+ int offset = (y * size + x) * 4;
+ for (int c = 0; c < 3; c++) Assert.InRange((double)pixels[offset + c], expected - 1.1, expected + 1.1);
+ Assert.Equal(255, pixels[offset + 3]);
+ }
+ }
+ foreach (float depth in MemoryMarshal.Cast(seam.ReadBackLevel0ForTests(depths[i])))
+ Assert.Equal(0.375f, depth);
+ }
+ seam.Present();
+ GpuTest.AssertClean(seam);
+ }
+ }
+
+ ///
+ /// The OPTIMUMAO variant (docs/vulkan.md#ambient-occlusion C.9, C.11): the GTAO visibility is
+ /// fetched at render resolution with no min-of-two-rows, attenuated by vanilla's water, fog and
+ /// OIT term (gPosition.w + 0.75 * (1 - revealage)), and still multiplies only colour 0.
+ ///
+ [SkippableFact]
+ public unsafe void GtaoVisibilityIsComposedWithTheVanillaAttenuationAndNoRowMin()
+ {
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device");
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ const int size = 8, frames = 4;
+ const float positionW = 0.25f, revealage = 0.6f;
+ var files = ShaderCorpus.LoadShaderFiles();
+ int program = GpuTest.LinkProgram(
+ seam,
+ files["scene-ssao.vsh"],
+ files["scene-ssao.fsh"].Replace("#version 330 core", "#version 330 core\n#define SSAOLEVEL 2\n#define OPTIMUMAO 1"),
+ "scene-ssao-gtao");
+
+ var ao = new byte[size * size * 4];
+ for (int y = 0; y < size; y++)
+ for (int x = 0; x < size; x++)
+ for (int c = 0; c < 4; c++) ao[(y * size + x) * 4 + c] = (byte)(y % 2 == 0 ? 64 : 192);
+ int visibility;
+ fixed (byte* data = ao) visibility = seam.CreateTexture2DRaw(size, size, 0x8058, (IntPtr)data, 4);
+
+ int position = seam.CreateTexture2D(size, size, EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);
+ int reveal = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4);
+ int inputs = seam.CreateFramebuffer(size, size);
+ seam.AttachTexture(inputs, EnumFramebufferAttachment.ColorAttachment0, position, 0);
+ seam.AttachTexture(inputs, EnumFramebufferAttachment.ColorAttachment1, reveal, 0);
+ seam.SetDrawBuffers(inputs, 3);
+
+ var colors = new int[frames][];
+ var targets = new int[frames];
+ for (int i = 0; i < frames; i++)
+ {
+ targets[i] = seam.CreateFramebuffer(size, size);
+ colors[i] = new int[3];
+ for (int slot = 0; slot < 3; slot++)
+ {
+ colors[i][slot] = seam.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4);
+ seam.AttachTexture(targets[i], (EnumFramebufferAttachment)(36064 + slot), colors[i][slot], 0);
+ }
+ }
+
+ seam.SetViewport(0, 0, size, size);
+ seam.SetCullFace(false);
+ seam.SetDepthTest(false);
+ seam.SetSamplerUnit(program, "ssaoScene", 0);
+ seam.SetSamplerUnit(program, "gPositionScene", 1);
+ seam.SetSamplerUnit(program, "revealageScene", 2);
+ seam.SetUniform(program, seam.GetUniformLocation(program, "invRenderHeight"), 1f / size);
+ seam.SetUniform(program, seam.GetUniformLocation(program, "optimumAoMode"), 1);
+ for (int i = 0; i < frames; i++)
+ {
+ seam.BeginFrame();
+ seam.BindFramebuffer(inputs);
+ seam.ClearColor(0, 0f, 0f, 0f, positionW);
+ seam.ClearColor(1, revealage, 0f, 0f, 1f);
+ seam.BindFramebuffer(targets[i]);
+ seam.SetDrawBuffers(targets[i], 7);
+ seam.ClearColor(0, 0.75f, 0.75f, 0.75f, 1f);
+ seam.ClearColor(1, 0.25f, 0.25f, 0.25f, 1f); // glow: never touched
+ seam.ClearColor(2, 0.5f, 0.5f, 0.5f, 1f);
+ seam.SetDrawBuffers(targets[i], 1);
+ seam.SetBlend(true, EnumBlendMode.Multiply);
+ seam.UseProgram(program);
+ seam.BindTexture(0, visibility);
+ seam.BindTexture(1, position);
+ seam.BindTexture(2, reveal);
+ seam.DrawFullscreenTriangle();
+ seam.SetBlend(true, EnumBlendMode.Standard);
+ seam.SetDrawBuffers(targets[i], 7);
+ seam.Present();
+ }
+
+ double attenuate = positionW + (1.0 - Math.Round(revealage * 255) / 255.0) * 0.75;
+ seam.BeginFrame();
+ for (int i = 0; i < frames; i++)
+ {
+ for (int slot = 0; slot < 3; slot++)
+ {
+ byte[] pixels = seam.ReadBackLevel0ForTests(colors[i][slot]);
+ for (int y = 0; y < size; y++)
+ for (int x = 0; x < size; x++)
+ {
+ double v = (y % 2 == 0 ? 64 : 192) / 255.0;
+ double factor = Math.Clamp(1.0 - (1.0 - v) * (1.0 - attenuate), 0.0, 1.0);
+ double expected = slot switch
+ {
+ 0 => Math.Round(0.75 * 255) * factor,
+ 1 => Math.Round(0.25 * 255),
+ _ => Math.Round(0.5 * 255),
+ };
+ int offset = (y * size + x) * 4;
+ for (int c = 0; c < 3; c++) Assert.InRange((double)pixels[offset + c], expected - 1.1, expected + 1.1);
+ }
+ }
+ }
+ seam.Present();
+ GpuTest.AssertClean(seam);
+ }
+ }
+}
+}
+
+// Source: Optimum.Render.Vulkan.Tests/SsaoNoiseAlphaTests.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+using System;
+using System.Runtime.InteropServices;
+using Optimum.Render.Vulkan;
+using Optimum.Render.Vulkan.Platform;
+using Vintagestory.API.Config;
+using Vintagestory.API.MathTools;
+using Xunit;
+using Xunit.Abstractions;
+
+///
+/// Parity gap from the Phase 0 and Phase 1 dumps: framebuffer slot 13 (SSAO) colour
+/// attachment 1, the 16x16 rotation-noise texture, read alpha 1.0 in every texel on
+/// OpenGL and 0.0 on Vulkan.
+///
+/// Not a masked write (no shader renders into that texture): the GL path allocates
+/// GL_RGBA32F and uploads GL_RGB float data, and GL's pixel transfer fills the absent
+/// alpha with 1. The device path uploaded four channels with a padding 0, and the device
+/// copies channels verbatim. The fix is in the data ();
+/// these tests read the texture back through the parity dump's own readback, inside a
+/// frame, with sync + best-practices validation on.
+///
+public class SsaoNoiseAlphaTests
+{
+ private const int NoiseSize = 16;
+ private const int GlRgba32f = 0x8814; // 34836, what both paths allocate
+
+ private readonly ITestOutputHelper _output;
+
+ public SsaoNoiseAlphaTests(ITestOutputHelper output) => _output = output;
+
+ ///
+ /// The GL path's generation verbatim (ClientPlatformWindows.SetupDefaultFrameBuffers):
+ /// three floats per texel, uploaded as GL_RGB.
+ ///
+ private static float[] GlRgbNoise(Random random)
+ {
+ float[] rgb = new float[NoiseSize * NoiseSize * 3];
+ Vec3f direction = new Vec3f();
+ for (int texel = 0; texel < NoiseSize * NoiseSize; texel++)
+ {
+ direction.Set((float)random.NextDouble() * 2f - 1f, (float)random.NextDouble() * 2f - 1f, 0f).Normalize();
+ rgb[texel * 3] = direction.X;
+ rgb[texel * 3 + 1] = direction.Y;
+ rgb[texel * 3 + 2] = direction.Z;
+ }
+ return rgb;
+ }
+
+ ///
+ /// Same colour values and the same random draws as the GL path, alpha 1 where GL fills
+ /// it, and the stream position afterwards unchanged, so the kernel drawn next matches.
+ ///
+ [Fact]
+ public void NoiseMatchesTheGlUploadIncludingTheFilledAlpha()
+ {
+ var glRandom = new Random(5);
+ var deviceRandom = new Random(5);
+ float[] rgb = GlRgbNoise(glRandom);
+ float[] rgba = VulkanClientPlatform.BuildOptimumSsaoNoise(deviceRandom, NoiseSize);
+
+ Assert.Equal(NoiseSize * NoiseSize * 4, rgba.Length);
+ for (int texel = 0; texel < NoiseSize * NoiseSize; texel++)
+ {
+ Assert.Equal(rgb[texel * 3], rgba[texel * 4]);
+ Assert.Equal(rgb[texel * 3 + 1], rgba[texel * 4 + 1]);
+ Assert.Equal(rgb[texel * 3 + 2], rgba[texel * 4 + 2]);
+ Assert.Equal(1f, rgba[texel * 4 + 3]);
+ }
+ Assert.Equal(glRandom.NextDouble(), deviceRandom.NextDouble());
+ }
+
+ ///
+ /// GPU readback through , the path that
+ /// writes 13-SSAO-color1-rgba32f.alpha.pfm. The platform's noise reads alpha 1.0 in all
+ /// 256 texels with the GL colour values; the pre-fix upload (identical colour, padding
+ /// alpha 0) on the same device reads 0.0, which is the Vulkan dump before the fix.
+ ///
+ [SkippableFact]
+ public void SsaoNoiseTextureReadsAlphaOneLikeOpenGl()
+ {
+ Skip.IfNot(GpuTest.TryCreateDevice(_output, out VulkanDevice? device), "No usable Vulkan device.");
+
+ float[] noise = VulkanClientPlatform.BuildOptimumSsaoNoise(new Random(5), NoiseSize);
+ float[] preFix = (float[])noise.Clone();
+ for (int texel = 0; texel < NoiseSize * NoiseSize; texel++) preFix[texel * 4 + 3] = 0f;
+ float[] glRgb = GlRgbNoise(new Random(5));
+
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ int fixedTexture = Upload(seam, noise);
+ int preFixTexture = Upload(seam, preFix);
+
+ seam.BeginFrame();
+ OptimumTextureReadback? fixedReadback = seam.ReadTextureForParity(fixedTexture);
+ OptimumTextureReadback? preFixReadback = seam.ReadTextureForParity(preFixTexture);
+ seam.Present();
+ GpuTest.AssertClean(seam);
+
+ Assert.NotNull(fixedReadback);
+ Assert.NotNull(preFixReadback);
+ Assert.Equal(GlRgba32f, fixedReadback!.GlInternalFormat);
+ Assert.Equal(NoiseSize, fixedReadback.Width);
+ Assert.Equal(NoiseSize, fixedReadback.Height);
+ Assert.NotNull(fixedReadback.Floats);
+ Assert.NotNull(preFixReadback!.Floats);
+
+ int alphaOne = 0;
+ int preFixAlphaZero = 0;
+ for (int texel = 0; texel < NoiseSize * NoiseSize; texel++)
+ {
+ Assert.Equal(glRgb[texel * 3], fixedReadback.Floats![texel * 4]);
+ Assert.Equal(glRgb[texel * 3 + 1], fixedReadback.Floats[texel * 4 + 1]);
+ Assert.Equal(glRgb[texel * 3 + 2], fixedReadback.Floats[texel * 4 + 2]);
+ if (fixedReadback.Floats[texel * 4 + 3] == 1f) alphaOne++;
+ if (preFixReadback.Floats![texel * 4 + 3] == 0f) preFixAlphaZero++;
+ }
+ _output.WriteLine("alpha 1.0 texels: " + alphaOne + "/256; pre-fix upload alpha 0.0 texels: " + preFixAlphaZero + "/256");
+ Assert.Equal(NoiseSize * NoiseSize, preFixAlphaZero);
+ Assert.Equal(NoiseSize * NoiseSize, alphaOne);
+ }
+ }
+
+ private static int Upload(VulkanDevice seam, float[] texels)
+ {
+ GCHandle handle = GCHandle.Alloc(texels, GCHandleType.Pinned);
+ try
+ {
+ return seam.CreateTexture2DRaw(NoiseSize, NoiseSize, GlRgba32f, handle.AddrOfPinnedObject(), 16);
+ }
+ finally
+ {
+ handle.Free();
+ }
+ }
+}
+}
+
+// Source: Optimum.Render.Vulkan.Tests/SsaoTemporalDitherTests.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+using System;
+using System.Collections.Generic;
+using Optimum.Render.Vulkan;
+using Vintagestory.API.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+///
+/// GTAO roadmap step 2. Vanilla's ssao.fsh rotates its sample kernel with a Bayer-128
+/// dither locked to the screen grid: under a jittered camera every surface point draws
+/// a different kernel every frame, and a temporal accumulator can only fight that, never
+/// average it. Optimum's override advances the dither by the golden ratio per frame,
+/// using the temporal pipeline's own frame index, so successive frames sample
+/// complementary spiral directions.
+///
+/// The two things worth pinning are both here, on the real shader, run on the device:
+/// with the temporal pipeline on (TAAMOTION 1) the AO of a fixed scene changes between
+/// consecutive frames, and with it off (TAAMOTION 0) it does not change at all - the
+/// override has to be byte-identical to vanilla there, because a per-frame-varying
+/// dither with nothing accumulating behind it is strictly worse than a fixed one.
+///
+public class SsaoTemporalDitherTests(ITestOutputHelper output)
+{
+ private const int Size = 128;
+ private const int GlRgba32f = 0x8814;
+ private const int GlRgba8 = 0x8058;
+ private const int KernelSize = 64;
+
+ /// Frame index the AO is rendered at, in order. The third repeats the first.
+ private static readonly float[] FrameIndices = [0f, 1f, 0f, 2f];
+
+ [SkippableTheory]
+ [InlineData(1)]
+ [InlineData(2)]
+ public void TheAoVariesPerFrameOnlyWhileTheTemporalPipelineIsOn(int quality)
+ {
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No Vulkan device");
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped vanilla assets.");
+
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ var files = ShaderCorpus.LoadShaderFiles();
+
+ byte[][] withTemporal = RenderSequence(seam, files, quality, taaMotion: 1);
+ byte[][] withoutTemporal = RenderSequence(seam, files, quality, taaMotion: 0);
+
+ // The scene really is occluded: several AO levels, not a flat 255. Printed
+ // because a scene that stops occluding would make every comparison below
+ // trivially pass with the temporal term removed.
+ var seen = new SortedSet();
+ for (int i = 0; i < Size * Size; i++) seen.Add(withTemporal[0][i * 4]);
+ output.WriteLine("distinct AO values in frame 0: " + seen.Count + " -> " + string.Join(",", seen));
+ Assert.True(seen.Count > 1, "the test scene produced no occlusion at all");
+
+ int changed = Differing(withTemporal[0], withTemporal[1]);
+ int repeated = Differing(withTemporal[0], withTemporal[2]);
+ int changedAgain = Differing(withTemporal[1], withTemporal[3]);
+ output.WriteLine($"SSAOLEVEL {quality}: temporal on - frame 0 vs 1: {changed} px differ, " +
+ $"1 vs 2: {changedAgain} px differ, frame 0 vs the frame that repeats index 0: {repeated} px differ");
+
+ // A different frame index really does rotate the kernel: a good share of the
+ // scene lands on a different occlusion value. The threshold is a tenth of the
+ // image, far below what the pass actually moves, so it fails on "the uniform
+ // is ignored", not on a driver's rounding.
+ Assert.True(changed > Size * Size / 10, $"AO did not vary between frames (only {changed} px)");
+ Assert.True(changedAgain > Size * Size / 10, $"AO did not vary between frames (only {changedAgain} px)");
+ // ... and the same index gives the same AO, so what varies is the frame index
+ // and not the device.
+ Assert.Equal(0, repeated);
+
+ int off = Differing(withoutTemporal[0], withoutTemporal[1]);
+ int offAgain = Differing(withoutTemporal[1], withoutTemporal[3]);
+ output.WriteLine($"SSAOLEVEL {quality}: temporal off - frame 0 vs 1: {off} px differ, 1 vs 3: {offAgain} px differ");
+ Assert.Equal(0, off);
+ Assert.Equal(0, offAgain);
+
+ GpuTest.AssertClean(seam);
+ }
+ }
+
+ ///
+ /// Renders the same scene once per entry in , each into its
+ /// own target, and reads them all back afterwards - the sequence has to complete before
+ /// any CPU wait, or the readback is what makes the frames differ.
+ ///
+ private static byte[][] RenderSequence(
+ VulkanDevice seam, Dictionary files, int quality, int taaMotion)
+ {
+ string defines = "#version 330 core\n#define SSAOLEVEL " + quality + "\n#define TAAMOTION " + taaMotion + "\n";
+ int program = GpuTest.LinkProgram(
+ seam,
+ files["ssao.vsh"],
+ files["ssao.fsh"].Replace("#version 330 core", defines),
+ "ssao" + quality + "-taa" + taaMotion);
+
+ (float[] positions, float[] normals) = Scene();
+ int gPosition = UploadFloat(seam, positions);
+ int gNormal = UploadFloat(seam, normals);
+ int revealage = UploadOpaqueRed(seam);
+ // The pass never reads texNoise (the dither replaced it), but the sampler is
+ // declared, so it still needs something bound.
+ int noise = UploadFloat(seam, new float[Size * Size * 4]);
+
+ int frames = FrameIndices.Length;
+ var targets = new int[frames];
+ var colors = new int[frames];
+ for (int i = 0; i < frames; i++)
+ {
+ targets[i] = seam.CreateFramebuffer(Size, Size);
+ colors[i] = seam.CreateTexture2DRaw(Size, Size, GlRgba8, IntPtr.Zero, 4);
+ seam.AttachTexture(targets[i], EnumFramebufferAttachment.ColorAttachment0, colors[i], 0);
+ }
+
+ seam.SetViewport(0, 0, Size, Size);
+ seam.SetCullFace(false);
+ seam.SetDepthTest(false);
+ seam.SetBlend(false, EnumBlendMode.Standard);
+ seam.SetSamplerUnit(program, "gPosition", 0);
+ seam.SetSamplerUnit(program, "gNormal", 1);
+ seam.SetSamplerUnit(program, "texNoise", 2);
+ seam.SetSamplerUnit(program, "revealage", 3);
+ seam.SetUniform(program, seam.GetUniformLocation(program, "screenSize"), (float)Size, (float)Size);
+ seam.SetUniformMatrix(program, seam.GetUniformLocation(program, "projection"), Projection());
+ seam.SetUniformArray3(program, seam.GetUniformLocation(program, "samples"), KernelSize, Kernel());
+
+ // -1 when the shader was built without the temporal pipeline: the uniform is
+ // inside #if TAAMOTION == 1, exactly like the pass's own set is inside
+ // "if (OptimumConfig.EffectiveTaa)".
+ int frameIndexLocation = seam.GetUniformLocation(program, "temporalFrameIndex");
+ Assert.Equal(taaMotion == 1, frameIndexLocation >= 0);
+
+ for (int i = 0; i < frames; i++)
+ {
+ seam.BeginFrame();
+ seam.BindFramebuffer(targets[i]);
+ seam.SetDrawBuffers(targets[i], 1);
+ seam.ClearColor(0, 0, 0, 0, 1);
+ seam.UseProgram(program);
+ if (frameIndexLocation >= 0) seam.SetUniform(program, frameIndexLocation, FrameIndices[i]);
+ seam.BindTexture(0, gPosition);
+ seam.BindTexture(1, gNormal);
+ seam.BindTexture(2, noise);
+ seam.BindTexture(3, revealage);
+ seam.DrawFullscreenTriangle();
+ seam.Present();
+ }
+
+ var readback = new byte[frames][];
+ seam.BeginFrame();
+ for (int i = 0; i < frames; i++) readback[i] = seam.ReadBackLevel0ForTests(colors[i]);
+ seam.Present();
+ return readback;
+ }
+
+ ///
+ /// A view-space G-buffer the projection above reproduces exactly: a wall at 10 m
+ /// with 4x4 pixel blocks raised to 9.7 m, normals facing the camera. The raised
+ /// blocks put occluders within the shader's depth window all over the image, so
+ /// the AO of most pixels depends on which way the kernel points.
+ ///
+ private static (float[] Positions, float[] Normals) Scene()
+ {
+ var positions = new float[Size * Size * 4];
+ var normals = new float[Size * Size * 4];
+ for (int y = 0; y < Size; y++)
+ {
+ for (int x = 0; x < Size; x++)
+ {
+ bool raised = ((x / 4) + (y / 4)) % 2 == 0;
+ float distance = raised ? 9.7f : 10f;
+ float ndcX = (x + 0.5f) / Size * 2f - 1f;
+ float ndcY = (y + 0.5f) / Size * 2f - 1f;
+ int texel = (y * Size + x) * 4;
+ positions[texel] = ndcX * distance * TanHalfFov;
+ positions[texel + 1] = ndcY * distance * TanHalfFov;
+ positions[texel + 2] = -distance;
+ positions[texel + 3] = 0f; // attenuate
+ normals[texel + 2] = 1f;
+ normals[texel + 3] = 0f; // leavesHack off
+ }
+ }
+ return (positions, normals);
+ }
+
+ private const float TanHalfFov = 0.7002075f; // tan(70 deg / 2)
+
+ /// Column-major perspective, 70 degrees, square, near 0.1, far 100.
+ private static float[] Projection()
+ {
+ var m = new float[16];
+ float f = 1f / TanHalfFov;
+ const float near = 0.1f, far = 100f;
+ m[0] = f;
+ m[5] = f;
+ m[10] = (far + near) / (near - far);
+ m[11] = -1f;
+ m[14] = 2f * far * near / (near - far);
+ return m;
+ }
+
+ ///
+ /// A hemisphere kernel in the shape the client uploads: directions in the +z
+ /// hemisphere, pulled towards the origin quadratically so near samples dominate.
+ ///
+ private static float[] Kernel()
+ {
+ var random = new Random(7);
+ var kernel = new float[KernelSize * 3];
+ for (int i = 0; i < KernelSize; i++)
+ {
+ double x = random.NextDouble() * 2.0 - 1.0;
+ double y = random.NextDouble() * 2.0 - 1.0;
+ double z = random.NextDouble();
+ double length = Math.Sqrt(x * x + y * y + z * z);
+ double scale = 0.1 + 0.9 * ((double)i / KernelSize) * ((double)i / KernelSize);
+ kernel[i * 3] = (float)(x / length * scale);
+ kernel[i * 3 + 1] = (float)(y / length * scale);
+ kernel[i * 3 + 2] = (float)(z / length * scale);
+ }
+ return kernel;
+ }
+
+ private static unsafe int UploadFloat(VulkanDevice seam, float[] texels)
+ {
+ fixed (float* data = texels) return seam.CreateTexture2DRaw(Size, Size, GlRgba32f, (IntPtr)data, 16);
+ }
+
+ /// revealage = 1 everywhere, which is "nothing transparent here".
+ private static unsafe int UploadOpaqueRed(VulkanDevice seam)
+ {
+ var texels = new byte[Size * Size * 4];
+ for (int i = 0; i < Size * Size; i++) texels[i * 4] = 255;
+ fixed (byte* data = texels) return seam.CreateTexture2DRaw(Size, Size, GlRgba8, (IntPtr)data, 4);
+ }
+
+ private static int Differing(byte[] a, byte[] b)
+ {
+ Assert.Equal(a.Length, b.Length);
+ int count = 0;
+ for (int texel = 0; texel < a.Length / 4; texel++)
+ {
+ if (a[texel * 4] != b[texel * 4]) count++;
+ }
+ return count;
+ }
+}
+}
diff --git a/Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs b/Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs
new file mode 100644
index 00000000..b22a30ce
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/AmbientOcclusionTests.cs
@@ -0,0 +1,646 @@
+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/vulkan.md#ambient-occlusion 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 = GpuTest.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 = GpuTest.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((1u, 2u, 2u), Counts(GtaoSettings.ForStableTemporal(GtaoPreset.Low)));
+ Assert.Equal((3u, 3u, 2u), Counts(GtaoSettings.ForStableTemporal(GtaoPreset.Medium)));
+ Assert.Equal((3u, 3u, 2u), Counts(GtaoSettings.ForStableTemporal(GtaoPreset.High)));
+ Assert.Equal((9u, 3u, 2u), Counts(GtaoSettings.ForStableTemporal(GtaoPreset.Ultra)));
+ 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..7fcf449b
--- /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.
+//
+// Some integration and stress cases are long-running. Keep real-device tests
+// serialized until the GLFW and device lifecycle are isolated from pure cases.
+[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/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/DeviceValidationTests.cs b/Optimum.Render.Vulkan.Tests/DeviceValidationTests.cs
new file mode 100644
index 00000000..36905885
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/DeviceValidationTests.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Optimum.Render.Vulkan.Core;
+using Silk.NET.Vulkan;
+using Vintagestory.API.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+public class DeviceValidationTests
+{
+ private readonly ITestOutputHelper _output;
+ public DeviceValidationTests(ITestOutputHelper output) => _output = output;
+
+ private VulkanContext Open(List messages)
+ {
+ var options = GpuTest.ContextOptions(messages);
+ options.ValidationFeatures = "sync,best";
+ bool created = VulkanContext.TryCreate(options, out VulkanContext? context, out string? reason);
+ // An explicitly requested device must fail visibly if it cannot be used.
+ if (Environment.GetEnvironmentVariable(GpuTest.DeviceIndexVariable) != null)
+ Assert.True(created, reason);
+ Skip.IfNot(created, "No usable Vulkan device: " + reason);
+ try
+ {
+ _output.WriteLine($"device={context!.Capabilities.DeviceName}; vendor={context.Capabilities.VendorId:X}; driver={context.Capabilities.DriverVersion}; API={context.Capabilities.ApiVersion}");
+ _output.WriteLine("validation=" + context.ValidationSettingsApplied);
+ Assert.True(context.ValidationEnabled, "Khronos validation must be installed for GPU acceptance.");
+ Assert.DoesNotContain("NOT APPLIED", context.ValidationSettingsApplied);
+ Assert.False(string.IsNullOrWhiteSpace(context.ValidationSettingsApplied));
+ string? expected = Environment.GetEnvironmentVariable("OPTIMUM_TEST_DEVICE_NAME");
+ if (!string.IsNullOrWhiteSpace(expected))
+ Assert.Contains(expected, context.Capabilities.DeviceName, StringComparison.OrdinalIgnoreCase);
+ return context;
+ }
+ catch { context!.Dispose(); throw; }
+ }
+
+ [Fact]
+ public void ColorWriteOverridesNeverRequireUnsupportedFeatures()
+ {
+ foreach (bool enable in new[] { false, true })
+ foreach (bool mask in new[] { false, true })
+ foreach (string forced in new[] { "enable", "mask", "pipeline" })
+ {
+ var requested = DeviceCaps.ParseColorWriteTier(forced);
+ string actual = DeviceCaps.Token(DeviceCaps.SelectColorWriteTier(enable, mask, requested));
+ string expected = forced == "pipeline" ? "pipeline"
+ : forced == "enable" && enable ? "enable" : mask ? "mask" : "pipeline";
+ Assert.Equal(expected, actual);
+ }
+ Assert.Null(DeviceCaps.ParseColorWriteTier("unknown"));
+ Assert.Equal(DeviceCaps.ParseColorWriteTier("mask"), DeviceCaps.ParseColorWriteTier(" MASK "));
+ }
+
+ [SkippableFact]
+ public void SelectedDeviceCreatesTheActualSharedPipelineLayout()
+ {
+ var messages = new List();
+ using (var context = Open(messages))
+ {
+ Assert.Empty(DescriptorIndexingFloor.Missing(context.Capabilities.DescriptorIndexing));
+ using var layout = SharedPipelineLayout.CreateStandalone(context);
+ Assert.NotEqual(0UL, layout.Layout.Handle);
+ Assert.NotEqual(0UL, layout.TextureSetLayout.Handle);
+ if (context.CheckpointsAvailable) Assert.NotNull(context.ReadQueueCheckpoints());
+ }
+ // Include destruction in the validation boundary.
+ ValidationAssert.NoErrors(messages);
+ }
+
+ [SkippableFact]
+ public void TranslatedFullscreenDrawUsesGlFramebufferCoordinates()
+ {
+ var device = GpuTest.CreateDevice(_output);
+ try
+ {
+ var caps = device.ContextForTests.Capabilities;
+ Assert.True(caps.ApiVersion >= VulkanContext.MinimumApiVersion);
+ Assert.True(caps.MultiDrawIndirect);
+ Assert.True(caps.MaxBoundDescriptorSets >= 3);
+ Assert.True(caps.MaxSamplerLodBias >= 2f);
+ Assert.True(caps.MaxImageDimension2D >= 4096);
+ int program = GpuTest.LinkProgram(device, """
+ #version 330 core
+ out vec2 uv;
+ 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);
+ uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5);
+ }
+ """, """
+ #version 330 core
+ in vec2 uv;
+ out vec4 color;
+ void main() { color = vec4(uv, 0, 1); }
+ """, "coordinate-convention");
+ const int size = 32;
+ int image = device.CreateTexture2DRaw(size, size, 0x8058, IntPtr.Zero, 4);
+ int target = device.CreateFramebuffer(size, size);
+ device.AttachTexture(target, EnumFramebufferAttachment.ColorAttachment0, image, 0);
+ device.SetDrawBuffers(target, 1);
+ device.BeginFrame(); device.BindFramebuffer(target); device.UseProgram(program);
+ device.SetViewport(0, 0, size, size); device.SetDepthTest(false); device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard); device.DrawFullscreenTriangle();
+ byte[] pixels = device.ReadBackLevel0ForTests(image);
+ Assert.Equal(size * size * 4, pixels.Length);
+ for (int y = 0; y < size; y++)
+ for (int x = 0; x < size; x++)
+ {
+ int i = (y * size + x) * 4;
+ int expectedX = (int)Math.Round((x + 0.5) * 255.0 / size);
+ int expectedY = (int)Math.Round((y + 0.5) * 255.0 / size);
+ Assert.InRange((int)pixels[i], expectedX - 1, expectedX + 1);
+ Assert.InRange((int)pixels[i + 1], expectedY - 1, expectedY + 1);
+ Assert.Equal(0, pixels[i + 2]); Assert.Equal(255, pixels[i + 3]);
+ }
+ device.Present();
+ }
+ finally { device.Dispose(); }
+ GpuTest.AssertClean(device);
+ }
+
+ [SkippableFact]
+ public unsafe void SynchronizationValidationDetectsAnActualMissingImageBarrier()
+ {
+ var messages = new List();
+ using (var context = Open(messages))
+ using (var commands = new SetupQueue(context))
+ using (var textures = new TextureManager(context, commands.Uploads))
+ {
+ var source = textures.Get(textures.Create(4, 4, Format.R8G8B8A8Unorm))!;
+ var target = textures.Get(textures.Create(4, 4, Format.R8G8B8A8Unorm))!;
+ commands.SubmitAndWait(commandBuffer =>
+ {
+ textures.TransitionTexture(commandBuffer, source, ImageLayout.TransferSrcOptimal);
+ textures.TransitionTexture(commandBuffer, target, ImageLayout.TransferDstOptimal);
+ var region = new ImageCopy
+ {
+ SrcSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
+ DstSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1),
+ Extent = new Extent3D(4, 4, 1),
+ };
+ for (int i = 0; i < 2; i++)
+ context.Api.CmdCopyImage(commandBuffer, source.Image, ImageLayout.TransferSrcOptimal,
+ target.Image, ImageLayout.TransferDstOptimal, 1, ®ion);
+ });
+ }
+ var snapshot = ValidationAssert.Snapshot(messages);
+ foreach (string message in snapshot) _output.WriteLine(message);
+ Assert.Contains(snapshot, m => m.Contains("SYNC-HAZARD-WRITE-AFTER-WRITE", StringComparison.Ordinal));
+ // This one deliberate hazard is the control, not an allowance for renderer errors.
+ ValidationAssert.NoErrors(snapshot.Where(m => !m.Contains("SYNC-HAZARD-WRITE-AFTER-WRITE", StringComparison.Ordinal)).ToArray());
+ }
+}
+
+internal static class ValidationAssert
+{
+ public static List Snapshot(IReadOnlyCollection messages)
+ {
+ lock (messages) return new List(messages);
+ }
+
+ public static bool IsSynchronization(string message) =>
+ message.Contains("[SYNC-", StringComparison.Ordinal);
+
+ public static void NoErrors(IReadOnlyCollection messages)
+ {
+ string[] errors = Snapshot(messages).Where(m =>
+ m.StartsWith(VulkanContext.ErrorPrefix, StringComparison.Ordinal) || IsSynchronization(m)).ToArray();
+ Assert.True(errors.Length == 0, "validation errors:\n" + string.Join("\n", errors));
+ }
+
+ // Retained for existing call sites while the feature suite is replaced.
+ public static void NoSyncHazards(IReadOnlyCollection messages, string callerFile = "")
+ {
+ string[] hazards = Snapshot(messages).Where(IsSynchronization).ToArray();
+ Assert.True(hazards.Length == 0, "synchronization hazards:\n" + string.Join("\n", hazards));
+ }
+}
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..ff84ce54
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs
@@ -0,0 +1,118 @@
+// Source: Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/ModPassFixtureSystem.cs
+namespace Optimum.Render.Vulkan.Tests.Fixtures.ModPassFixture
+{
+using System;
+using Vintagestory.API.Client;
+using Vintagestory.API.Common;
+
+///
+/// A fixture mod that follows docs/vulkan.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);
+ }
+}
+}
+
+// Source: Optimum.Render.Vulkan.Tests/Fixtures/ClientApiStub.cs
+namespace Optimum.Render.Vulkan.Tests.Fixtures
+{
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Vintagestory.API.Client;
+
+///
+/// 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/modinfo.json b/Optimum.Render.Vulkan.Tests/Fixtures/ModPassFixture/modinfo.json
new file mode 100644
index 00000000..2b7e69ff
--- /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.md.",
+ "version": "1.0.0",
+ "side": "Client",
+ "dependencies": { "game": "" }
+}
diff --git a/Optimum.Render.Vulkan.Tests/FramePlanningTests.cs b/Optimum.Render.Vulkan.Tests/FramePlanningTests.cs
new file mode 100644
index 00000000..5de9e8df
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/FramePlanningTests.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Optimum.Render.Vulkan.Graph;
+using Silk.NET.Vulkan;
+using Xunit;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+public class FramePlanningTests
+{
+ private static PassSignature Pass(int target, bool transient = true, params int[] reads) => new()
+ {
+ NameId = target, Width = 32, Height = 24, FormatsId = 1,
+ Attachments = new[] { new AttachmentUse(target, ResourceUsage.ColorWrite, transient) },
+ Reads = reads,
+ };
+
+ [Fact]
+ public void PostChainDiscardsOnlyTransientInitialContents()
+ {
+ var frame = new[] { Pass(1), Pass(2, true, 1), Pass(3, true, 2), Pass(4, false, 3) };
+ var plan = FramePlan.Build(frame);
+ for (int i = 0; i < 3; i++)
+ {
+ Assert.Equal(AttachmentLoadOp.DontCare, plan.LoadOp(i, 0));
+ }
+ Assert.Equal(AttachmentLoadOp.Load, plan.LoadOp(3, 0));
+ }
+
+ [Theory]
+ [InlineData(ResourceUsage.ColorBlend)]
+ [InlineData(ResourceUsage.DepthReadOnly)]
+ public void InitialReadsPreserveExistingContents(ResourceUsage firstUse)
+ {
+ var pass = Pass(1);
+ pass.Attachments[0] = new AttachmentUse(1, firstUse, true);
+ var plan = FramePlan.Build(new[] { pass });
+ Assert.Equal(AttachmentLoadOp.Load, plan.LoadOp(0, 0));
+ }
+
+ [Fact]
+ public void CachedPlansOwnTheirDataAndMismatchesFallBackToLoading()
+ {
+ var frame = new[] { Pass(1), Pass(2, true, 1) };
+ var original = frame.Select(p => p.Clone()).ToArray();
+ var plan = FramePlan.Build(frame);
+ frame[0].Attachments[0] = new AttachmentUse(99, ResourceUsage.ColorBlend, false);
+ frame[1].Reads[0] = 99;
+ Assert.True(plan.Matches(original));
+ Assert.False(plan.Matches(frame));
+ var graph = new FrameGraph { Enabled = true };
+ foreach (var pass in original) graph.OpenPass(pass, true);
+ graph.EndFrame();
+ foreach (var pass in frame)
+ {
+ int index = graph.OpenPass(pass, true);
+ Assert.Equal(AttachmentLoadOp.Load, graph.PlannedLoad(index, 0));
+ }
+ }
+
+ [Fact]
+ public void AlternatingHistoryShapesReusePlansUntilTheirInputsChange()
+ {
+ var graph = new FrameGraph { Enabled = true };
+ var a = Pass(10, true, 11);
+ var b = Pass(11, true, 10);
+ graph.OpenPass(a, true); graph.EndFrame();
+ var planA = graph.Plan;
+ graph.OpenPass(b, true); graph.EndFrame();
+ var planB = graph.Plan;
+ for (int i = 0; i < 6; i++)
+ {
+ var pass = i % 2 == 0 ? a : b;
+ int index = graph.OpenPass(pass, true);
+ Assert.Equal(AttachmentLoadOp.DontCare, graph.PlannedLoad(index, 0));
+ graph.EndFrame();
+ Assert.Same(i % 2 == 0 ? planA : planB, graph.Plan);
+ }
+ var resized = a.Clone(); resized.Height++;
+ int changed = graph.OpenPass(resized, true);
+ Assert.Equal(AttachmentLoadOp.Load, graph.PlannedLoad(changed, 0));
+ graph.EndFrame();
+ Assert.NotSame(planA, graph.Plan);
+ }
+
+ [Fact]
+ public void AllocatorPreservesInclusiveLifetimesAndDistinctImageDescriptions()
+ {
+ var random = new Random(901);
+ var intervals = Enumerable.Range(0, 96).Select(i =>
+ {
+ int first = random.Next(20);
+ return (Description: new TransientImageDesc(32, 24, Format.R8G8B8A8Unorm, (uint)(i % 3 + 1)), FirstPass: first, LastPass: first + random.Next(5));
+ }).OrderBy(i => i.FirstPass).ToArray();
+ var allocator = new TransientAllocator(new ImageBacking(), aliasing: true);
+ allocator.BeginFrame();
+ int[] slots = intervals.Select(i => allocator.Acquire(i.Description, i.FirstPass, i.LastPass).Slot).ToArray();
+ for (int i = 0; i < intervals.Length; i++)
+ for (int j = i + 1; j < intervals.Length; j++)
+ if (slots[i] == slots[j])
+ {
+ Assert.Equal(intervals[i].Description, intervals[j].Description);
+ Assert.True(intervals[i].LastPass < intervals[j].FirstPass || intervals[j].LastPass < intervals[i].FirstPass);
+ }
+ int minimum = intervals.GroupBy(i => i.Description).Sum(group =>
+ Enumerable.Range(0, 25).Max(pass => group.Count(i => i.FirstPass <= pass && pass <= i.LastPass)));
+ Assert.Equal(minimum, slots.Distinct().Count());
+ }
+
+ private sealed class ImageBacking : ITransientBacking
+ {
+ private int _next;
+ public int Create(TransientImageDesc desc) => ++_next;
+ public void Destroy(int textureId) { }
+ public ulong BytesOf(int textureId) => 0;
+ public bool TryDescribe(int textureId, out TransientImageDesc desc) { desc = default; return false; }
+ public void Discard(int textureId) { }
+ public void Rebind(int logicalTextureId, int physicalTextureId) { }
+ public void RestoreBindings() { }
+ }
+
+ [Theory]
+ [InlineData(ResourceUsage.StorageReadCompute)]
+ [InlineData(ResourceUsage.StorageWrite)]
+ [InlineData(ResourceUsage.StorageReadWrite)]
+ public void ConsecutiveComputeUsesOrderPreviousWritesEvenInTheSameStage(ResourceUsage next)
+ {
+ var state = new ResourceStateTracker(1, 1, false);
+ var transitions = new List();
+ state.Require(0, 1, 0, 1, ResourceUsage.StorageWrite, false, transitions);
+ transitions.Clear();
+ state.Require(0, 1, 0, 1, next, false, transitions);
+ var barrier = Assert.Single(transitions).Sides;
+ Assert.Equal(ImageLayout.General, barrier.OldLayout);
+ Assert.Equal(ImageLayout.General, barrier.NewLayout);
+ Assert.True((barrier.SrcAccess & AccessFlags2.ShaderStorageWriteBit) != 0);
+ Assert.True((barrier.DstStage & PipelineStageFlags2.ComputeShaderBit) != 0);
+ }
+
+ [Fact]
+ public void ASecondReaderStageMustAlsoObserveTheProducer()
+ {
+ var state = new ResourceStateTracker(1, 1, false);
+ var transitions = new List();
+ state.Require(0, 1, 0, 1, ResourceUsage.TransferDst, false, transitions);
+ state.Require(0, 1, 0, 1, ResourceUsage.SampleVertex, false, transitions);
+ transitions.Clear();
+ state.Require(0, 1, 0, 1, ResourceUsage.SampleFragment, false, transitions);
+ var barrier = Assert.Single(transitions).Sides;
+ Assert.True((barrier.SrcAccess & AccessFlags2.TransferWriteBit) != 0);
+ Assert.True((barrier.DstStage & PipelineStageFlags2.FragmentShaderBit) != 0);
+ transitions.Clear();
+ state.Require(0, 1, 0, 1, ResourceUsage.SampleFragment, false, transitions);
+ Assert.Empty(transitions);
+ }
+
+ [Fact]
+ public void MipTransitionsDoNotChangeUntouchedLevelsAndDiscardStillOrdersPriorUses()
+ {
+ var state = new ResourceStateTracker(3, 1, false);
+ var transitions = new List();
+ state.Require(0, 3, 0, 1, ResourceUsage.TransferDst, false, transitions);
+ state.Require(1, 1, 0, 1, ResourceUsage.SampleFragment, false, transitions);
+ Assert.Equal(ImageLayout.TransferDstOptimal, state.StateOf(0, 0).Layout);
+ Assert.Equal(ImageLayout.ShaderReadOnlyOptimal, state.StateOf(1, 0).Layout);
+ Assert.Equal(ImageLayout.TransferDstOptimal, state.StateOf(2, 0).Layout);
+ state.Discard(); transitions.Clear();
+ state.Require(1, 1, 0, 1, ResourceUsage.ColorWrite, false, transitions);
+ var barrier = Assert.Single(transitions).Sides;
+ Assert.Equal(ImageLayout.Undefined, barrier.OldLayout);
+ Assert.True((barrier.SrcStage & PipelineStageFlags2.FragmentShaderBit) != 0);
+ Assert.Equal(ImageLayout.ColorAttachmentOptimal, barrier.NewLayout);
+ }
+
+ [Fact]
+ public void ComputeMipBindingsCoverOddExtentsAndRejectFeedbackOnTheSameLevel()
+ {
+ var pass = new ComputePassDeclaration
+ {
+ Bindings = new[] { new ComputeBinding(0, 1, ComputeAccess.Sampled), new ComputeBinding(1, 1, ComputeAccess.StorageWrite, 1) },
+ Dispatches = new[] { ComputeDispatch.Covering(1) },
+ };
+ ComputeImageInfo? Image(int _) => new ComputeImageInfo(35, 19, 4, 1);
+ Assert.Null(ComputePassPlanner.Validate(pass, Image));
+ Assert.Equal((3u, 2u, 1u), ComputePassPlanner.Groups(pass.Dispatches[0], pass, Image, 8, 8));
+ pass.Bindings[1] = pass.Bindings[1] with { BaseMip = 0 };
+ Assert.NotNull(ComputePassPlanner.Validate(pass, Image));
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/GlShapedDevice.cs b/Optimum.Render.Vulkan.Tests/GlShapedDevice.cs
new file mode 100644
index 00000000..f6a6dcd4
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/GlShapedDevice.cs
@@ -0,0 +1,277 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using Optimum.Render.Vulkan.Core;
+using Optimum.Render.Vulkan.Graph;
+using Optimum.Render.Vulkan.Platform;
+using Silk.NET.Vulkan;
+using Vintagestory.API.Client;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// The OpenGL-shaped calls the GPU tests are written in, on a bare :
+/// set state, bind, clear, draw, read back. The device has no GL state machine any more; these
+/// record what a test states into a per device, exactly as
+/// records the client's statements, and every draw goes through
+/// , the platform's generic native draw. A test therefore renders through
+/// the route the client's own unrecognised draws take.
+///
+/// A device a owns shares the platform's record, program and
+/// target (VulkanDevice.OwnerPlatform): a fixture that states on the device and then calls one of
+/// the platform's bodies draws with that state, and the device's draws are the platform's.
+///
+/// Semantics kept from the removed emulation: a bind names the target the next clears, draws and
+/// readbacks address (nothing is drawn before one); a declared pass (DeclarePass) gives the
+/// following draws its name, slots, reads and flags until the next declaration, bind of another
+/// target or EndPass; a clear honours the stated draw buffers and colour mask, and a depth
+/// clear the depth mask.
+///
+internal static class GlShapedDevice
+{
+ private sealed class Record
+ {
+ public StatedRenderState Stated = new();
+ public VulkanClientPlatform? Platform;
+ private int program;
+
+ /// glUseProgram: the platform's record when a platform owns the device.
+ public int Program
+ {
+ get => Platform?.statedProgram ?? program;
+ set
+ {
+ program = value;
+ if (Platform != null) Platform.statedProgram = value;
+ }
+ }
+
+ public int Bound;
+ public PassDeclaration? Declared;
+ public string? LastRefusal;
+ public long Refusals;
+
+ }
+
+ private static readonly ConditionalWeakTable Records = new();
+ private static Record Of(VulkanDevice device)
+ {
+ if (!Records.TryGetValue(device, out Record? record))
+ {
+ record = new Record();
+ Records.Add(device, record);
+ Record captured = record;
+ device.FramebufferDeleted += id =>
+ {
+ captured.Stated.ForgetFramebuffer(id);
+ if (captured.Bound == id) captured.Bound = 0;
+ };
+ }
+ if (record.Platform == null)
+ {
+ VulkanClientPlatform? owner = PlatformOf(device);
+ if (owner != null)
+ {
+ record.Stated = owner.stated;
+ record.Platform = owner;
+ }
+ }
+ return record;
+ }
+
+ /// The platform that owns the device, if any.
+ private static VulkanClientPlatform? PlatformOf(VulkanDevice device) => device.OwnerPlatform;
+
+ /// The default target's id as the stated record keys it.
+ private static int Key(VulkanDevice device, int framebufferId) =>
+ framebufferId != 0 && framebufferId == device.DefaultFramebufferId ? PassDeclaration.DefaultFramebuffer : framebufferId;
+
+ extension(VulkanDevice device)
+ {
+ /// The state the tests stated on this device.
+ internal StatedRenderState StatedForTests => Of(device).Stated;
+
+ /// Draws the stated route refused, and the last reason.
+ internal long StatedRefusalsForTests => Of(device).Refusals;
+
+ internal string? LastStatedRefusalForTests => Of(device).LastRefusal;
+
+ // ------------------------------------------------------------ fixed function
+
+ public void UseProgram(int programId) => Of(device).Program = programId;
+
+ public void SetViewport(int x, int y, int width, int height) =>
+ Of(device).Stated.Viewport = new Rect2D(new Offset2D(x, y),
+ new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height)));
+
+ /// glScissor, clipped to the positive quadrant as the platform clips it.
+ public void SetScissor(int x, int y, int width, int height)
+ {
+ int clippedX = Math.Max(0, x);
+ int clippedY = Math.Max(0, y);
+ width -= clippedX - x;
+ height -= clippedY - y;
+ Of(device).Stated.Scissor = new Rect2D(new Offset2D(clippedX, clippedY),
+ new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height)));
+ }
+
+ public void SetScissorEnabled(bool enabled) => Of(device).Stated.ScissorEnabled = enabled;
+
+ public bool ScissorEnabled => Of(device).Stated.ScissorEnabled;
+
+ public void SetDepthTest(bool enabled) => Of(device).Stated.DepthTest = enabled;
+
+ public void SetDepthMask(bool enabled) => Of(device).Stated.DepthWrite = enabled;
+
+ public void SetDepthFunc(int glFunc) => Of(device).Stated.DepthCompare = GlEnums.CompareOpFrom(glFunc);
+
+ public void SetCullFace(bool enabled) => Of(device).Stated.CullEnabled = enabled;
+
+ public void SetCullFaceMode(bool back) => Of(device).Stated.CullBack = back;
+
+ public void SetBlend(bool enabled, EnumBlendMode mode)
+ {
+ StatedRenderState stated = Of(device).Stated;
+ stated.SetBlendEnabled(enabled);
+ stated.SetBlendMode(mode);
+ }
+
+ public void SetBlendEnabled(bool enabled) => Of(device).Stated.SetBlendEnabled(enabled);
+
+ public void SetBlendFuncSeparate(int attachment, int srcColor, int dstColor, int srcAlpha, int dstAlpha) =>
+ Of(device).Stated.SetSlotFunc(attachment, srcColor, dstColor, srcAlpha, dstAlpha);
+
+ public void SetBlendEquation(int attachment, int equation) =>
+ Of(device).Stated.SetSlotEquation(attachment, equation);
+
+ public void SetColorMask(bool r, bool g, bool b, bool a) => Of(device).Stated.SetColorMask(r, g, b, a);
+
+ public void SetStencilTest(bool enabled) => Of(device).Stated.StencilTest = enabled;
+
+ public void SetWireframe(bool enabled) => Of(device).Stated.Wireframe = enabled;
+
+ public void SetLineWidth(float width) => Of(device).Stated.LineWidth = width;
+
+ // ------------------------------------------------------------ textures
+
+ public void BindTexture(int unit, int textureId) => Of(device).Stated.BindTexture(unit, textureId);
+
+ public void BindTextureCube(int unit, int textureId) => Of(device).Stated.BindTexture(unit, textureId);
+
+ public void BindSampler(int unit, int samplerId) => Of(device).Stated.BindSampler(unit, samplerId);
+
+ // ------------------------------------------------------------ targets
+
+ public void SetDrawBuffers(int framebufferId, int attachmentMask) =>
+ Of(device).Stated.SetDrawBuffers(Key(device, framebufferId), (uint)attachmentMask);
+
+ public void BindFramebuffer(int framebufferId)
+ {
+ Record record = Of(device);
+ int key = Key(device, framebufferId);
+ if (record.Declared != null && record.Declared.FramebufferId != key) SetDeclared(record, null);
+ record.Bound = key;
+ if (record.Platform == null) return;
+ // A raw bind is what a fork renderer does on the platform: its draws address it too.
+ if (key > 0) record.Platform.NoteForkFramebuffer(key);
+ else record.Platform.CurrentFrameBuffer = null!;
+ }
+
+ public void BindDefaultFramebuffer() => device.BindFramebuffer(PassDeclaration.DefaultFramebuffer);
+
+ /// The following draws belong to this pass (0: the bound target, -1: the default one).
+ internal void DeclarePass(PassDeclaration declaration)
+ {
+ Record record = Of(device);
+ int key = declaration.FramebufferId == PassDeclaration.BoundFramebuffer
+ ? Target(record)
+ : Key(device, declaration.FramebufferId);
+ device.BindFramebuffer(key);
+ SetDeclared(record, new PassDeclaration
+ {
+ Name = declaration.Name,
+ FramebufferId = key,
+ ColorSlots = declaration.ColorSlots,
+ Reads = declaration.Reads,
+ TransientSlots = declaration.TransientSlots,
+ Flags = declaration.Flags,
+ });
+ }
+
+ /// Ends the declared pass and closes its scope.
+ internal void EndPass()
+ {
+ SetDeclared(Of(device), null);
+ device.EndNativePass();
+ device.EndStagePass();
+ }
+
+ public void ClearColor(int attachment, float r, float g, float b, float a)
+ {
+ Record record = Of(device);
+ int target = Target(record);
+ if (target == 0) return;
+ if (((record.Stated.DrawBuffers(target) >> attachment) & 1) == 0 || record.Stated.ColorMask == 0) return;
+ device.ClearNativeColor(target, attachment, r, g, b, a);
+ }
+
+ public void ClearDepth(float depth)
+ {
+ Record record = Of(device);
+ int target = Target(record);
+ if (target == 0 || !record.Stated.DepthWrite) return;
+ device.ClearNativeDepth(target, depth);
+ }
+
+ public void ClearStencil() { }
+
+ /// Colour attachment 0 of the bound target, in its own channel order.
+ public void ReadDefaultFramebuffer(int x, int y, int width, int height, IntPtr destination)
+ {
+ int target = Target(Of(device));
+ if (target == 0) return;
+ device.ReadFramebufferColor(target, x, y, width, height, destination);
+ }
+
+ // ------------------------------------------------------------ draws
+
+ public void DrawMesh(int meshId) => Draw(device, meshId, 1, null, null, 0);
+
+ public void DrawMeshInstanced(int meshId, int instanceCount)
+ {
+ if (instanceCount > 0) Draw(device, meshId, instanceCount, null, null, 0);
+ }
+
+ public void DrawMeshMulti(int meshId, int[] indicesStarts, int[] indicesSizes, int groupCount, bool ssbo) =>
+ Draw(device, meshId, 1, indicesStarts, indicesSizes, groupCount);
+
+ public void DrawFullscreenTriangle() => Draw(device, 0, 1, null, null, 0);
+ }
+
+ /// The target a clear, draw or readback addresses: the platform's current one when a platform owns the device.
+ private static int Target(Record record) => record.Platform?.CurrentTargetId ?? record.Bound;
+
+ /// The declared pass, held where the draws read it.
+ private static void SetDeclared(Record record, PassDeclaration? declared)
+ {
+ record.Declared = declared;
+ if (record.Platform != null) record.Platform.statedPass = declared;
+ }
+
+ private static void Draw(VulkanDevice device, int meshId, int instances, int[]? starts, int[]? sizes, int groupCount)
+ {
+ Record record = Of(device);
+ if (record.Platform != null)
+ {
+ record.Platform.RecordStatedDraw(meshId, instances, starts, sizes, groupCount);
+ return;
+ }
+ if (record.Bound == 0 || record.Program <= 0) return;
+ if (!StatedDraw.Record(device, record.Stated, record.Program, record.Bound, meshId, instances,
+ starts, sizes, groupCount, out string? refusal, record.Declared) && refusal != null)
+ {
+ record.Refusals++;
+ record.LastRefusal = refusal;
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/GpuTest.cs b/Optimum.Render.Vulkan.Tests/GpuTest.cs
new file mode 100644
index 00000000..1586227c
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/GpuTest.cs
@@ -0,0 +1,352 @@
+// Source: Optimum.Render.Vulkan.Tests/GpuTest.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using Optimum.Render.Vulkan.Core;
+using Vintagestory.API.Config;
+using Vintagestory.API.Client;
+using Xunit;
+using Xunit.Abstractions;
+
+///
+/// The one place GPU tests get a or a
+/// from.
+///
+/// Every context and device comes up with the validation layers and, by
+/// default, synchronization validation plus best practices ("sync,best").
+/// Plain validation missed the R32F-history and masked-clear bugs that only
+/// synchronization validation names (TAA P2 and P4, 2026-09-10/11), so the
+/// suite runs with it unless OPTIMUM_TEST_VALIDATION_FEATURES says otherwise;
+/// an empty value turns the extra features off.
+///
+internal static class GpuTest
+{
+ public const string ValidationFeaturesVariable = "OPTIMUM_TEST_VALIDATION_FEATURES";
+ public const string DefaultValidationFeatures = "sync,best";
+ public const string DeviceIndexVariable = "OPTIMUM_TEST_DEVICE_INDEX";
+
+ public static string ValidationFeatures =>
+ Environment.GetEnvironmentVariable(ValidationFeaturesVariable) ?? DefaultValidationFeatures;
+
+ private static int DeviceIndex
+ {
+ get
+ {
+ string? value = Environment.GetEnvironmentVariable(DeviceIndexVariable);
+ if (value == null) return -1;
+ Assert.True(int.TryParse(value, out int index) && index >= 0,
+ DeviceIndexVariable + " must be a nonnegative device index.");
+ return index;
+ }
+ }
+
+ private static void CheckContext(VulkanContext context, ITestOutputHelper output)
+ {
+ output.WriteLine($"device={context.Capabilities.DeviceName}; vendor={context.Capabilities.VendorId:X}; driver={context.Capabilities.DriverVersion}; validation={context.ValidationSettingsApplied}");
+ Assert.True(context.ValidationEnabled, "Khronos validation must be installed for GPU acceptance.");
+ Assert.DoesNotContain("NOT APPLIED", context.ValidationSettingsApplied);
+ if (!string.IsNullOrWhiteSpace(ValidationFeatures))
+ Assert.False(string.IsNullOrWhiteSpace(context.ValidationSettingsApplied));
+ string? expected = Environment.GetEnvironmentVariable("OPTIMUM_TEST_DEVICE_NAME");
+ if (!string.IsNullOrWhiteSpace(expected))
+ Assert.Contains(expected, context.Capabilities.DeviceName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void CheckUnavailable(string? reason, ITestOutputHelper output)
+ {
+ output.WriteLine("Vulkan unavailable: " + reason);
+ Assert.True(Environment.GetEnvironmentVariable(DeviceIndexVariable) == null
+ && Environment.GetEnvironmentVariable("OPTIMUM_TEST_DEVICE_NAME") == null,
+ "The explicitly requested GPU could not initialize: " + reason);
+ }
+
+ public static VulkanContext CreateContext(ITestOutputHelper output, List? messages = null)
+ {
+ Skip.IfNot(TryCreateContext(output, messages, out var context), "No usable Vulkan device.");
+ return context!;
+ }
+
+ public static VulkanDevice CreateDevice(ITestOutputHelper output, Action? configure = null)
+ {
+ Skip.IfNot(TryCreateDevice(output, out var device, configure), "No usable Vulkan device.");
+ return device!;
+ }
+
+ /// Headless, validated options; receives every layer message.
+ public static VulkanContextOptions ContextOptions(List? messages = null) => new()
+ {
+ Headless = true,
+ EnableValidation = true,
+ ValidationFeatures = ValidationFeatures,
+ PreferredDeviceIndex = DeviceIndex,
+ DebugCallback = messages == null ? null : Recorder(messages),
+ };
+
+ ///
+ /// Appends under the list's own lock: the layers call back from whichever
+ /// thread made the Vulkan call, and the asserts snapshot under the same lock.
+ ///
+ public static Action Recorder(List messages) => message =>
+ {
+ lock (messages) messages.Add(message);
+ };
+
+ public static bool TryCreateContext(ITestOutputHelper output, List? messages, out VulkanContext? context)
+ {
+ bool created = VulkanContext.TryCreate(ContextOptions(messages), out context, out string? failureReason);
+ if (!created) CheckUnavailable(failureReason, output);
+ else
+ {
+ try { CheckContext(context!, output); }
+ catch { context!.Dispose(); throw; }
+ }
+ return created;
+ }
+
+ private static readonly ConditionalWeakTable> DeviceMessages = new();
+
+ ///
+ /// A device, not yet initialised, whose context will come up with the
+ /// suite's validation features and record every layer message for
+ /// . The client's own diagnostics channel still
+ /// receives them too.
+ ///
+ public static VulkanDevice NewDevice()
+ {
+ var messages = new List();
+ // Blocking pipeline creation: these tests read pixels back after one frame, and a
+ // background compile would skip that frame's draw. The async path has its own tests
+ // (PipelineCacheTests), which set SynchronousPipelines = false before Initialize.
+ var device = new VulkanDevice { DebugMode = true, SynchronousPipelines = true };
+ device.ConfigureContextOptions = options =>
+ {
+ options.EnableValidation = true;
+ options.ValidationFeatures = ValidationFeatures;
+ options.PreferredDeviceIndex = DeviceIndex;
+ Action? client = options.DebugCallback;
+ options.DebugCallback = message =>
+ {
+ lock (messages) messages.Add(message);
+ client?.Invoke(message);
+ };
+ };
+ DeviceMessages.Add(device, messages);
+ return device;
+ }
+
+ public static bool TryCreateDevice(ITestOutputHelper output, out VulkanDevice? device, Action? configure = null)
+ {
+ VulkanDevice created = NewDevice();
+ try
+ {
+ configure?.Invoke(created);
+ if (created.Initialize(IntPtr.Zero, 0, 0, out string failureReason))
+ {
+ CheckContext(created.ContextForTests, output);
+ device = created;
+ return true;
+ }
+ CheckUnavailable(failureReason, output);
+ }
+ catch { created.Dispose(); throw; }
+ created.Dispose();
+ device = null;
+ return false;
+ }
+
+ /// The layer messages a device from has recorded so far.
+ public static List MessagesOf(VulkanDevice seam) =>
+ seam is VulkanDevice device && DeviceMessages.TryGetValue(device, out List? messages)
+ ? messages
+ : new List();
+
+ ///
+ /// Fails on validation errors, synchronization hazards and device errors
+ /// such as rejected shaders or failed Vulkan calls.
+ ///
+ public static void AssertClean(VulkanDevice seam)
+ {
+ List messages = MessagesOf(seam);
+ ValidationAssert.NoErrors(messages);
+
+ string? diagnostics = seam.GetError();
+ if (string.IsNullOrEmpty(diagnostics)) return;
+
+ // GetError repeats the layer messages (sanitised for the client's
+ // string.Format); those were judged above, so only the rest counts here.
+ string residual = diagnostics;
+ foreach (string message in ValidationAssert.Snapshot(messages))
+ {
+ residual = residual.Replace(message.Replace('{', '[').Replace('}', ']'), "");
+ }
+
+ var remaining = new List();
+ foreach (string line in residual.Split('\n'))
+ {
+ if (line.Trim().Length > 0) remaining.Add(line);
+ }
+ Assert.True(remaining.Count == 0, "device diagnostics:\n" + string.Join("\n", remaining));
+ }
+ ///
+ /// A minimal shader stand-in. The client passes its own IShader and
+ /// IShaderProgram implementations across the seam, so the device must work
+ /// against the interfaces rather than any concrete type.
+ ///
+ internal sealed class TestShader : IShader
+ {
+ public EnumShaderType Type { get; set; }
+ public string Code { get; set; } = "";
+ public string PrefixCode { get; set; } = "";
+ public bool Compile() => true;
+ }
+
+ internal sealed class TestProgram : IShaderProgram
+ {
+ public int ProgramId { get; set; }
+ public string AssetDomain { get; set; } = "game";
+ public int PassId { get; set; }
+ public string PassName { get; set; } = "test";
+ public bool ClampTexturesToEdge { get; set; }
+ public IShader VertexShader { get; set; } = null!;
+ public IShader FragmentShader { get; set; } = null!;
+ public IShader GeometryShader { get; set; } = null!;
+ public bool Oit { get; set; } = true;
+ public bool Disposed => false;
+ public bool LoadError => false;
+ public Vintagestory.API.Datastructures.OrderedDictionary UBOs { get; } = new();
+
+ public void Use() { }
+ public void Stop() { }
+ public bool Compile() => true;
+ public void Dispose() { }
+ public void Uniform(string uniformName, float value) { }
+ public void Uniform(string uniformName, int value) { }
+ public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2f value) { }
+ public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec2i value) { }
+ public void Uniform(string uniformName, float valueX, float valueY) { }
+ public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec3f value) { }
+ public void Uniform(string uniformName, float valueX, float valueY, float valueZ) { }
+ public void Uniform(string uniformName, float valueX, float valueY, float valueZ, float valueW) { }
+ public void Uniform(string uniformName, Vintagestory.API.MathTools.Vec4f value) { }
+ public void Uniforms4(string uniformName, int count, float[] values) { }
+ public void UniformMatrix(string uniformName, float[] matrix) { }
+ public void BindTexture2D(string samplerName, int textureId, int textureNumber) { }
+ public void BindTextureCube(string samplerName, int textureId, int textureNumber) { }
+ public void UniformMatrices(string uniformName, int count, float[] matrix) { }
+ public void UniformMatrices4x3(string uniformName, int count, float[] matrix) { }
+ public bool HasUniform(string uniformName) => false;
+ }
+
+ internal static int LinkProgram(
+ VulkanDevice device, string vertexCode, string fragmentCode, string name = "test")
+ {
+ var vertex = new TestShader { Type = EnumShaderType.VertexShader, Code = vertexCode };
+ var fragment = new TestShader { Type = EnumShaderType.FragmentShader, Code = fragmentCode };
+
+ Assert.True(device.CompileShader(vertex));
+ Assert.True(device.CompileShader(fragment));
+
+ var program = new TestProgram { PassName = name, VertexShader = vertex, FragmentShader = fragment };
+ int programId = device.LinkProgram(program);
+ Assert.True(programId > 0, device.GetError() ?? "link failed");
+ return programId;
+ }
+
+}
+}
+
+// Source: Optimum.Render.Vulkan.Tests/SetupQueue.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+using System;
+using Optimum.Render.Vulkan.Core;
+using Silk.NET.Vulkan;
+
+///
+/// Component tests' stand-in for the synchronous setup submit the renderer no
+/// longer has (Phase 1B step 3 deleted VulkanCommands.SubmitAndWait).
+///
+/// It owns a Transfer timeline and an for managers
+/// used without a frame ring. appends the test's
+/// commands to the open upload batch, after every upload recorded so far, submits
+/// the batch on its own and waits for its Transfer value: the order a test wrote
+/// its calls in is the order the GPU runs them. Test code only; the renderer
+/// itself never waits for an upload.
+///
+internal sealed unsafe class SetupQueue : IDisposable
+{
+ private readonly VulkanContext _context;
+ private bool _disposed;
+
+ public FrameTimeline Timeline { get; }
+ public RetireQueue Retired { get; }
+ public UploadManager Uploads { get; }
+
+ public SetupQueue(VulkanContext context, ulong stagingPerSlot = 4UL << 20)
+ {
+ _context = context;
+ Timeline = new FrameTimeline(context);
+ Retired = new RetireQueue(Timeline);
+ Uploads = new UploadManager(context, Timeline, Retired, framesInFlight: 2, stagingPerSlot);
+ }
+
+ /// Records into the open upload batch, submits it and waits for it.
+ public void SubmitAndWait(Action record)
+ {
+ CommandBuffer commandBuffer = Uploads.BeginRecording(inlineInFrame: false);
+ try
+ {
+ record(commandBuffer);
+ }
+ finally
+ {
+ Uploads.EndRecording();
+ }
+
+ ulong transferValue = Uploads.SubmitStandalone();
+ Timeline.WaitForTransfer(transferValue, WaitSite.Readback);
+ Retired.Collect();
+ }
+
+ ///
+ /// Moves a standalone image between layouts with a broad synchronization2
+ /// barrier (all commands on both sides), for tests that drive raw images.
+ ///
+ public void TransitionImage(CommandBuffer commandBuffer, VulkanImage image, ImageLayout target, ImageAspectFlags aspect)
+ {
+ var barrier = new ImageMemoryBarrier2
+ {
+ SType = StructureType.ImageMemoryBarrier2,
+ SrcStageMask = PipelineStageFlags2.AllCommandsBit,
+ SrcAccessMask = AccessFlags2.MemoryWriteBit,
+ DstStageMask = PipelineStageFlags2.AllCommandsBit,
+ DstAccessMask = AccessFlags2.MemoryReadBit | AccessFlags2.MemoryWriteBit,
+ OldLayout = image.Layout,
+ NewLayout = target,
+ Image = image.Handle,
+ SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1),
+ };
+
+ var dependency = new DependencyInfo
+ {
+ SType = StructureType.DependencyInfo,
+ ImageMemoryBarrierCount = 1,
+ PImageMemoryBarriers = &barrier,
+ };
+
+ _context.Api.CmdPipelineBarrier2(commandBuffer, &dependency);
+ image.Layout = target;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ Uploads.Dispose();
+ Retired.DisposeAll();
+ Timeline.Dispose();
+ }
+}
+}
diff --git a/Optimum.Render.Vulkan.Tests/Graph/FrameExecutionTests.cs b/Optimum.Render.Vulkan.Tests/Graph/FrameExecutionTests.cs
new file mode 100644
index 00000000..7477f62c
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/Graph/FrameExecutionTests.cs
@@ -0,0 +1,891 @@
+// Source: Optimum.Render.Vulkan.Tests/ComputePassTests.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+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;
+
+///
+/// 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 = GpuTest.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 = GpuTest.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);
+ }
+ }
+}
+}
+
+// Source: Optimum.Render.Vulkan.Tests/FrameGraphFrameTests.cs
+namespace Optimum.Render.Vulkan.Tests
+{
+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;
+
+///
+/// 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 = GpuTest.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 = GpuTest.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 = GpuTest.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 = GpuTest.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.
+ _output.WriteLine($"centre: colour={texels["colour"][centre * 4]},{texels["colour"][centre * 4 + 1]},{texels["colour"][centre * 4 + 2]} " +
+ $"glow={texels["glow"][centre * 4]},{texels["glow"][centre * 4 + 1]},{texels["glow"][centre * 4 + 2]} " +
+ $"output={texels["output"][centre * 4]},{texels["output"][centre * 4 + 1]},{texels["output"][centre * 4 + 2]}");
+ 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 = GpuTest.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 = GpuTest.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/MotionWindowTests.cs b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs
new file mode 100644
index 00000000..08f22f76
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/MotionWindowTests.cs
@@ -0,0 +1,384 @@
+using System;
+using System.Linq;
+using Optimum.Render.Vulkan.Core;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.Config;
+using Xunit;
+using Xunit.Abstractions;
+using static Optimum.Render.Vulkan.Tests.MotionFixture;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// Phase 2 contract C4 on a real device, once per colour write tier (forced the
+/// way OPTIMUM_VULKAN_COLOR_WRITE_TIER forces it, through the context options):
+/// draw buffers and the TAA motion windows are write masks inside one rendering
+/// scope, never scope restarts. Every test reads back only after the draws, in
+/// the frame, and asserts bytes rather than approximations.
+///
+public class MotionWindowTests
+{
+ private readonly ITestOutputHelper _output;
+
+ public MotionWindowTests(ITestOutputHelper output) => _output = output;
+
+ private const int Size = 8;
+
+ private const string FullscreenVertex = """
+ #version 330 core
+ out vec2 uv;
+ void main(void)
+ {
+ float x = -1.0 + float((gl_VertexID & 1) << 2);
+ float y = -1.0 + float((gl_VertexID & 2) << 1);
+ gl_Position = vec4(x, y, 0.0, 1.0);
+ uv = vec2((x + 1.0) * 0.5, (y + 1.0) * 0.5);
+ }
+ """;
+
+ private static string FourOutputs(string colour, string glow, string motion, string extra) => $$"""
+ #version 330 core
+ layout(location = 0) out vec4 outColor;
+ layout(location = 1) out vec4 outGlow;
+ layout(location = 2) out vec4 outMotion;
+ layout(location = 3) out vec4 outExtra;
+ void main(void)
+ {
+ outColor = vec4({{colour}});
+ outGlow = vec4({{glow}});
+ outMotion = vec4({{motion}});
+ outExtra = vec4({{extra}});
+ }
+ """;
+
+ /// The OPTIMUM_VULKAN_COLOR_WRITE_TIER tokens.
+ public static TheoryData Tiers => new() { "enable", "mask", "pipeline" };
+
+ /// Every tier with the frame graph on and off (OPTIMUM_VULKAN_FRAMEGRAPH).
+ public static TheoryData TiersWithFrameGraph => new()
+ {
+ { "enable", true }, { "mask", true }, { "pipeline", true },
+ { "enable", false }, { "mask", false }, { "pipeline", false },
+ };
+
+ private static ColorWriteTier Tier(string token) =>
+ DeviceCaps.ParseColorWriteTier(token) ?? throw new ArgumentException("unknown tier " + token);
+
+ private bool TryCreateDevice(ColorWriteTier tier, out VulkanDevice? device)
+ {
+ VulkanDevice created = GpuTest.NewDevice();
+ Action? suite = created.ConfigureContextOptions;
+ created.ConfigureContextOptions = options =>
+ {
+ suite?.Invoke(options);
+ options.ColorWriteTier = tier;
+ };
+
+ if (!created.Initialize(IntPtr.Zero, 0, 0, out string failureReason))
+ {
+ _output.WriteLine("Vulkan unavailable: " + failureReason);
+ created.Dispose();
+ device = null;
+ return false;
+ }
+ if (created.ColorWriteTierForTests != tier)
+ {
+ _output.WriteLine("device lacks tier " + tier + "; selected " + created.ColorWriteTierForTests);
+ created.Dispose();
+ device = null;
+ return false;
+ }
+ device = created;
+ return true;
+ }
+
+ private sealed class Scene
+ {
+ public int Framebuffer;
+ public int Colour;
+ public int Glow;
+ public int Motion;
+ public int Extra;
+ }
+
+ /// Colour and glow RGBA8, motion RGBA16F, a fourth RGBA8 slot: Primary's shape with TAA on.
+ private static Scene CreateScene(VulkanDevice seam)
+ {
+ var scene = new Scene
+ {
+ Colour = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ Glow = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ Motion = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ Extra = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ };
+ scene.Framebuffer = seam.CreateFramebuffer(Size, Size);
+ seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment0, scene.Colour, 0);
+ seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment1, scene.Glow, 0);
+ seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment2, scene.Motion, 0);
+ seam.AttachTexture(scene.Framebuffer, EnumFramebufferAttachment.ColorAttachment3, scene.Extra, 0);
+ Assert.True(seam.CheckFramebufferComplete(scene.Framebuffer, out string status), status);
+ return scene;
+ }
+
+ private static void BaseState(VulkanDevice seam)
+ {
+ seam.SetViewport(0, 0, Size, Size);
+ seam.SetScissorEnabled(false);
+ seam.SetDepthTest(false);
+ seam.SetCullFace(false);
+ seam.SetBlend(false, EnumBlendMode.Standard);
+ seam.SetColorMask(true, true, true, true);
+ }
+
+ /// A frame that clears every attachment to a known value, with every draw buffer selected.
+ private static void SeedFrame(VulkanDevice seam, Scene scene)
+ {
+ seam.BeginFrame();
+ seam.BindFramebuffer(scene.Framebuffer);
+ BaseState(seam);
+ seam.SetDrawBuffers(scene.Framebuffer, 0b1111);
+ seam.ClearColor(0, 0f, 0f, 0f, 1f);
+ seam.ClearColor(1, 0.2f, 0.4f, 0.6f, 1f);
+ seam.ClearColor(2, 0.125f, 0.25f, 0.375f, 0.5f);
+ seam.ClearColor(3, 0.4f, 0.6f, 0.8f, 1f);
+ seam.Present();
+ }
+
+ private static void AssertEveryPixel(byte[] texels, int bytesPerPixel, byte[] expected, string what)
+ {
+ Assert.Equal(Size * Size * bytesPerPixel, texels.Length);
+ for (int p = 0; p < texels.Length; p += bytesPerPixel)
+ {
+ for (int c = 0; c < bytesPerPixel; c++)
+ {
+ if (texels[p + c] != expected[c])
+ {
+ Assert.Fail($"{what}: pixel {p / bytesPerPixel} byte {c} is {texels[p + c]}, expected {expected[c]}");
+ }
+ }
+ }
+ }
+
+ private static byte[] Half(float r, float g, float b, float a)
+ {
+ var bytes = new byte[8];
+ BitConverter.TryWriteBytes(bytes.AsSpan(0, 2), (Half)r);
+ BitConverter.TryWriteBytes(bytes.AsSpan(2, 2), (Half)g);
+ BitConverter.TryWriteBytes(bytes.AsSpan(4, 2), (Half)b);
+ BitConverter.TryWriteBytes(bytes.AsSpan(6, 2), (Half)a);
+ return bytes;
+ }
+
+ ///
+ /// A pass over Primary with the default colour set, a motion window (all
+ /// three), a motion-only window, a restore, a masked-out clear and an
+ /// all-false colour-mask clear. The fourth slot never has its draw buffer on
+ /// in the pass, though every program writes it: bit-identical to the previous
+ /// frame's seed. The motion-only draw writes exactly the motion attachment.
+ /// All of it in one rendering scope; no restart from a mask change.
+ ///
+ [SkippableTheory]
+ [MemberData(nameof(Tiers))]
+ public void MotionWindowsAreWriteMasksInsideOneScope(string tierToken)
+ {
+ ColorWriteTier tier = Tier(tierToken);
+ Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable.");
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ int opaque = GpuTest.LinkProgram(seam, FullscreenVertex,
+ FourOutputs("1.0, 0.0, 0.0, 1.0", "0.0, 1.0, 0.0, 1.0", "0.75, 0.75, 0.75, 0.75", "1.0, 1.0, 1.0, 1.0"),
+ "mw-opaque");
+ int motionOnly = GpuTest.LinkProgram(seam, FullscreenVertex,
+ FourOutputs("0.0, 0.0, 1.0, 1.0", "0.0, 0.0, 1.0, 1.0", "1.0, 0.5, 0.0, 1.0", "0.0, 0.0, 0.0, 0.0"),
+ "mw-motion-only");
+ Scene scene = CreateScene(seam);
+ SeedFrame(seam, scene);
+
+ seam.BeginFrame();
+ seam.BindFramebuffer(scene.Framebuffer);
+ BaseState(seam);
+ long scopesBefore = device!.ScopesOpenedForTests;
+
+ // Default colour set: colour and glow; motion and extra masked.
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0011);
+ seam.UseProgram(opaque);
+ seam.DrawFullscreenTriangle();
+
+ // Motion window (EnableMotionDrawBuffers), then the motion-only window.
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0111);
+ seam.DrawFullscreenTriangle();
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0100);
+ seam.UseProgram(motionOnly);
+ seam.DrawFullscreenTriangle();
+
+ // Restore, then two clears that must not land: motion masked out, and
+ // colour through an all-false glColorMask.
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0011);
+ seam.ClearColor(2, 0f, 0f, 0f, 0f);
+ seam.SetColorMask(false, false, false, false);
+ seam.ClearColor(0, 0f, 0f, 1f, 1f);
+ seam.SetColorMask(true, true, true, true);
+ seam.UseProgram(opaque);
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0001);
+ seam.DrawFullscreenTriangle();
+
+ long scopes = device.ScopesOpenedForTests - scopesBefore;
+ long maskRestarts = device.MaskRestartsForTests;
+ long splits = device.FeedbackSplitsForTests;
+
+ byte[] colour = device.ReadBackLevel0ForTests(scene.Colour);
+ byte[] glow = device.ReadBackLevel0ForTests(scene.Glow);
+ byte[] motion = device.ReadBackLevel0ForTests(scene.Motion);
+ byte[] extra = device.ReadBackLevel0ForTests(scene.Extra);
+ seam.Present();
+
+ _output.WriteLine($"tier={tier} scopes={scopes} mask_restarts={maskRestarts} feedback_splits={splits}");
+ Assert.Equal(1, scopes);
+ Assert.Equal(0, maskRestarts);
+ Assert.Equal(0, splits);
+
+ AssertEveryPixel(colour, 4, new byte[] { 255, 0, 0, 255 }, "colour");
+ AssertEveryPixel(glow, 4, new byte[] { 0, 255, 0, 255 }, "glow");
+ AssertEveryPixel(motion, 8, Half(1f, 0.5f, 0f, 1f), "motion (motion-only window wrote it last)");
+ AssertEveryPixel(extra, 4, new byte[] { 102, 153, 204, 255 }, "extra (draw buffer never on)");
+
+ GpuTest.AssertClean(seam);
+ }
+ }
+
+ ///
+ /// The OIT merge: standard blending on colour, additive (ONE, ONE) on the
+ /// motion attachment (ApplyOptimumMotionAccumulateBlendState), a program that
+ /// adds only to motion's blue. Red, green and alpha of the RGBA16F motion
+ /// texels stay bit-exact; glow, which the program does not write, is untouched.
+ ///
+ [SkippableTheory]
+ [MemberData(nameof(Tiers))]
+ public void OitMergeAdditiveOnMotionLeavesRedGreenAndAlphaBitExact(string tierToken)
+ {
+ ColorWriteTier tier = Tier(tierToken);
+ Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable.");
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ int merge = GpuTest.LinkProgram(seam, FullscreenVertex, """
+ #version 330 core
+ layout(location = 0) out vec4 outColor;
+ layout(location = 2) out vec4 outMotion;
+ void main(void)
+ {
+ outColor = vec4(1.0, 1.0, 1.0, 0.5);
+ outMotion = vec4(0.0, 0.0, 0.25, 0.0);
+ }
+ """, "mw-oit-merge");
+ Scene scene = CreateScene(seam);
+ SeedFrame(seam, scene);
+
+ seam.BeginFrame();
+ seam.BindFramebuffer(scene.Framebuffer);
+ BaseState(seam);
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0111);
+ seam.UseProgram(merge);
+ // ApplyTransparentMergeBlendState, then the motion accumulate override.
+ seam.SetBlend(true, EnumBlendMode.Standard);
+ seam.SetBlendFuncSeparate(0, 770, 771, 770, 771);
+ seam.SetBlendEquation(2, 32774);
+ seam.SetBlendFuncSeparate(2, 1, 1, 1, 1);
+ seam.DrawFullscreenTriangle();
+
+ // The accumulate override must not outlive the draw: a second draw with
+ // replace blending on motion keeps the scope and rewrites blue only.
+ seam.SetBlendFuncSeparate(2, 1, 0, 1, 0);
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0001);
+ seam.DrawFullscreenTriangle();
+
+ long maskRestarts = device!.MaskRestartsForTests;
+ byte[] colour = device.ReadBackLevel0ForTests(scene.Colour);
+ byte[] glow = device.ReadBackLevel0ForTests(scene.Glow);
+ byte[] motion = device.ReadBackLevel0ForTests(scene.Motion);
+ seam.Present();
+
+ Assert.Equal(0, maskRestarts);
+ byte[] seed = Half(0.125f, 0.25f, 0.375f, 0.5f);
+ byte[] merged = Half(0.125f, 0.25f, 0.625f, 0.5f);
+ for (int p = 0; p < motion.Length; p += 8)
+ {
+ Assert.Equal(seed.AsSpan(0, 4).ToArray(), motion.AsSpan(p, 4).ToArray()); // red, green
+ Assert.Equal(seed.AsSpan(6, 2).ToArray(), motion.AsSpan(p + 6, 2).ToArray()); // alpha
+ }
+ AssertEveryPixel(motion, 8, merged, "motion after additive merge");
+ AssertEveryPixel(glow, 4, new byte[] { 51, 102, 153, 255 }, "glow (not written by the merge)");
+
+ // Colour: two draws of (1,1,1,0.5) over black with SRC_ALPHA blending.
+ Assert.InRange(colour[0], 189, 193);
+ Assert.Equal(colour[0], colour[1]);
+
+ GpuTest.AssertClean(seam);
+ }
+ }
+
+ ///
+ /// The final composition shape: draw buffers select Primary 0 only while the
+ /// program samples Primary 1. The sampled slot leaves the draw's pass, colour receives
+ /// glow's texels exactly, glow keeps them; selecting glow again lets it rejoin and
+ /// a write lands. Validation stays clean. The stated draw declares its pass without the
+ /// sampled slot before any scope opens, so nothing splits on either path.
+ ///
+ [SkippableTheory]
+ [MemberData(nameof(TiersWithFrameGraph))]
+ public void CompositionSamplesAnAttachmentItsDrawBuffersExclude(string tierToken, bool frameGraph)
+ {
+ ColorWriteTier tier = Tier(tierToken);
+ Skip.IfNot(TryCreateDevice(tier, out VulkanDevice? device), "Vulkan or tier " + tier + " unavailable.");
+ using (device)
+ {
+ VulkanDevice seam = device!;
+ seam.FrameGraphEnabled = frameGraph;
+ int compose = GpuTest.LinkProgram(seam, FullscreenVertex, """
+ #version 330 core
+ uniform sampler2D glowTex;
+ in vec2 uv;
+ layout(location = 0) out vec4 outColor;
+ void main(void) { outColor = texture(glowTex, uv); }
+ """, "mw-compose");
+ int writeGlow = GpuTest.LinkProgram(seam, FullscreenVertex,
+ FourOutputs("0.0, 0.0, 0.0, 1.0", "1.0, 0.0, 0.0, 1.0", "0.0, 0.0, 0.0, 0.0", "0.0, 0.0, 0.0, 0.0"),
+ "mw-write-glow");
+ Scene scene = CreateScene(seam);
+ SeedFrame(seam, scene);
+
+ seam.BeginFrame();
+ seam.BindFramebuffer(scene.Framebuffer);
+ BaseState(seam);
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0001);
+ seam.ClearColor(0, 0f, 0f, 0f, 1f); // inference: opens the scope with glow in it, masked
+ seam.UseProgram(compose);
+ seam.SetSamplerUnit(compose, "glowTex", 0);
+ seam.BindTexture(0, scene.Glow);
+ seam.DrawFullscreenTriangle();
+ byte[] composed = device!.ReadBackLevel0ForTests(scene.Colour);
+ byte[] glowAfterCompose = device.ReadBackLevel0ForTests(scene.Glow);
+ long splitsAfterCompose = device.FeedbackSplitsForTests;
+
+ seam.BindTexture(0, 0);
+ seam.BindFramebuffer(scene.Framebuffer);
+ seam.SetDrawBuffers(scene.Framebuffer, 0b0010);
+ seam.UseProgram(writeGlow);
+ seam.DrawFullscreenTriangle();
+ long maskRestarts = device.MaskRestartsForTests;
+ byte[] glowAfterWrite = device.ReadBackLevel0ForTests(scene.Glow);
+ seam.Present();
+
+ _output.WriteLine($"tier={tier} frameGraph={frameGraph} splits_after_compose={splitsAfterCompose} mask_restarts={maskRestarts}");
+ Assert.Equal(0, splitsAfterCompose);
+ Assert.Equal(0, maskRestarts);
+ AssertEveryPixel(composed, 4, new byte[] { 51, 102, 153, 255 }, "colour = sampled glow");
+ AssertEveryPixel(glowAfterCompose, 4, new byte[] { 51, 102, 153, 255 }, "glow untouched by composition");
+ AssertEveryPixel(glowAfterWrite, 4, new byte[] { 255, 0, 0, 255 }, "glow written after it rejoined");
+
+ GpuTest.AssertClean(seam);
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/MotionWriterTests.cs b/Optimum.Render.Vulkan.Tests/MotionWriterTests.cs
new file mode 100644
index 00000000..3eff072d
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/MotionWriterTests.cs
@@ -0,0 +1,1530 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Optimum.Render.Vulkan.Core;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.Config;
+using Vintagestory.API.MathTools;
+using Xunit;
+using Xunit.Abstractions;
+using static Optimum.Render.Vulkan.Tests.MotionFixture;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+/// GPU contract for the terrain shaders' TAA motion attachment.
+public sealed class TerrainMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private static readonly float[] Identity =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ [SkippableTheory]
+ [InlineData("chunkopaque")]
+ [InlineData("chunktopsoil")]
+ public void TerrainWritesPreviousMinusCurrentPixelsAndDepth(string shader)
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!, shader);
+
+ // Identity projections map one NDC unit to half the render width.
+ // Both axes and both signs matter: a magnitude-only check misses
+ // flipped or swapped motion vectors.
+ Check(scene.Draw(0, 0, 0), 0, 0);
+ Check(scene.Draw(0.25f, 0, 0), 8, 0);
+ Check(scene.Draw(0, -0.125f, 0), 0, -4);
+ Check(scene.Draw(-0.1875f, 0.0625f, 0), -6, 2);
+
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableTheory]
+ [InlineData("chunkopaque")]
+ [InlineData("chunktopsoil")]
+ public void PreviousWarpMovesTheVectorWithoutWritingUncoveredPixels(string shader)
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!, shader);
+ float[] motion = scene.Draw(0, 0, 8);
+
+ // Vertexwarp's phase is zero for this face. Compute its displacement
+ // independently of the shader output, then convert NDC to pixels.
+ double warp = (Math.Sin(0) + Math.Sin(0.5) + Math.Sin(1) / 3) / 30 * 8;
+ float expectedX = (float)(warp * Size / 2);
+ Assert.True(expectedX > 1);
+ Check(motion, expectedX, 0);
+ Assert.InRange(Pixel(motion, 2, 2, 3), 0, 0.001f);
+
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private static void Check(float[] pixels, float expectedX, float expectedY)
+ {
+ Assert.InRange(Pixel(pixels, Size / 2, Size / 2, 0), expectedX - 0.05f, expectedX + 0.05f);
+ Assert.InRange(Pixel(pixels, Size / 2, Size / 2, 1), expectedY - 0.05f, expectedY + 0.05f);
+ Assert.InRange(Pixel(pixels, Size / 2, Size / 2, 3), 0.49f, 0.51f);
+ }
+
+ private static float Pixel(float[] pixels, int x, int y, int channel) =>
+ pixels[(y * Size + x) * 4 + channel];
+
+ private sealed class Scene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly int framebuffer;
+ private readonly int motion;
+ private readonly int mesh;
+
+ public Scene(VulkanDevice device, string shader)
+ {
+ this.device = device;
+ var variant = ShaderCorpus.Variants().Single(v => v.Name == "taa-no-ssao");
+ Assert.Equal(1, variant.TaaMotion);
+ Assert.Equal(2, variant.TaaMotionLocation);
+ var stages = ShaderCorpus.BuildProgram(shader, ShaderCorpus.LoadShaderFiles(),
+ ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, shader, oit: true);
+ Assert.True(device.GetUniformLocation(program, "taaRenderSize") >= 0,
+ shader + " has no motion writer");
+
+ int unit = BindEveryDeclaredSampler(device, device, program);
+ int atlas = CreateWhiteTexture(device);
+ foreach (string sampler in new[] { "terrainTex", "terrainTexLinear" })
+ {
+ device.SetSamplerUnit(program, sampler, unit);
+ device.BindTexture(unit++, atlas);
+ }
+
+ MotionTarget target = CreateMotionTarget(device, Size);
+ framebuffer = target.Framebuffer;
+ motion = target.MotionTexture;
+ mesh = CreateFaceMesh(device);
+ }
+
+ public float[] Draw(float cameraX, float cameraY, float previousWarp)
+ {
+ device.BeginFrame();
+ device.BindFramebuffer(framebuffer);
+ device.ClearColor(0, 0, 0, 0, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.UseProgram(program);
+
+ SetMatrix(device, program, "projectionMatrix", Identity);
+ SetMatrix(device, program, "modelViewMatrix", Identity);
+ SetMatrix(device, program, "prevProjectionMatrix", Identity);
+ SetMatrix(device, program, "prevModelViewMatrix", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixFar", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixNear", Identity);
+ SetFloat3(device, program, "cameraPosDelta", cameraX, cameraY, 0);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", 0, 0);
+ SetViewUniforms();
+ SetWarpUniforms(previousWarp);
+
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.SetDepthFunc(0x203); // GL_LEQUAL
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.DrawMesh(mesh);
+
+ float[] pixels = ReadMotion(device, motion, Size);
+ device.Present();
+ return pixels;
+ }
+
+ private void SetViewUniforms()
+ {
+ SetFloat(device, program, "viewDistance", 1024);
+ SetFloat(device, program, "viewDistanceLod0", 1024);
+ SetFloat(device, program, "alphaTest", 0.001f);
+ SetFloat(device, program, "zNear", 0.1f);
+ SetFloat(device, program, "zFar", 1024);
+ SetFloat(device, program, "shadowRangeFar", 1024);
+ SetFloat(device, program, "shadowRangeNear", 64);
+ SetFloat(device, program, "shadowMapWidthInv", 1);
+ SetFloat(device, program, "shadowMapHeightInv", 1);
+ SetFloat2(device, program, "blockTextureSize", 1, 1);
+ SetFloat3(device, program, "rgbaAmbientIn", 1, 1, 1);
+ SetFloat2(device, program, "frameSize", Size, Size);
+ }
+
+ private void SetWarpUniforms(float previousWarp)
+ {
+ SetInt(device, program, "perceptionEffectId", 1);
+ SetInt(device, program, "prevPerceptionEffectId", 1);
+ SetFloat(device, program, "windWaveIntensity", 1);
+ SetFloat(device, program, "waterWaveIntensity", 1);
+ SetFloat(device, program, "prevWindWaveIntensity", 1);
+ SetFloat(device, program, "prevWaterWaveIntensity", 1);
+ SetFloat(device, program, "globalWarpIntensity", 0);
+ SetFloat(device, program, "prevGlobalWarpIntensity", previousWarp);
+ }
+
+ }
+}
+
+/// Direct RGBA16F contract for the depth-tested sky motion pass.
+public sealed class SkyMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private const float Yaw = 0.1f;
+
+ private static float[] InverseJittered(float x, float y) =>
+ [
+ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, -1,
+ -2 * x / Size, -2 * y / Size, -1, 1,
+ ];
+
+ private static float[] PreviousViewProjection(float yaw)
+ {
+ float c = MathF.Cos(yaw), s = MathF.Sin(yaw);
+ return [c, 0, s, s, 0, 1, 0, 0, s, 0, -c, -c, 0, 0, -1, 0];
+ }
+
+ private static (float X, float Y) ExpectedMotion(int x, int y, float yaw, float jx, float jy)
+ {
+ float fragX = x + 0.5f, fragY = y + 0.5f;
+ float nx = fragX / Size * 2 - 1 - 2 * jx / Size;
+ float ny = fragY / Size * 2 - 1 - 2 * jy / Size;
+ float c = MathF.Cos(yaw), s = MathF.Sin(yaw);
+ float denominator = s * nx + c;
+ float previousX = ((c * nx - s) / denominator * 0.5f + 0.5f) * Size;
+ float previousY = (ny / denominator * 0.5f + 0.5f) * Size;
+ return (previousX - (fragX - jx), previousY - (fragY - jy));
+ }
+
+ [SkippableTheory]
+ [InlineData(1f, 0f, 0f)]
+ [InlineData(0f, 0f, 0f)]
+ [InlineData(0.5f, 0f, 0f)]
+ [InlineData(1f, 0.375f, -0.25f)]
+ [InlineData(1f, -0.5f, 0.5f)]
+ public void SkyRotationAndCloudCoverageWriteOnlyMotion(float coverage, float jx, float jy)
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ (float[] motion, byte[] color) = scene.Draw(coverage, jx, jy);
+ foreach ((int x, int y) in new[] { (32, 32), (48, 40), (20, 12), (56, 56) })
+ {
+ int i = (y * Size + x) * 4;
+ (float expectedX, float expectedY) = ExpectedMotion(x, y, Yaw, jx, jy);
+ Assert.InRange(motion[i], expectedX - 0.05f, expectedX + 0.05f);
+ Assert.InRange(motion[i + 1], expectedY - 0.05f, expectedY + 0.05f);
+ Assert.InRange(motion[i + 2], coverage + coverage * (1 - coverage) - 0.01f,
+ coverage + coverage * (1 - coverage) + 0.01f);
+ Assert.InRange(motion[i + 3], 0.99f, 1.01f);
+ }
+ Assert.True(Math.Abs(motion[(32 * Size + 32) * 4]) > 1);
+ byte[] expectedColor = [51, 102, 153, 255];
+ for (int i = 0; i < color.Length; i++)
+ Assert.InRange(color[i], expectedColor[i % 4] - 1, expectedColor[i % 4] + 1);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void SkyDepthTestPreservesEarlierSurfaceWriter()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ (float[] motion, _) = scene.Draw(1f, seedLeftHalf: true);
+ int covered = (32 * Size + 12) * 4;
+ int sky = (32 * Size + 52) * 4;
+ Assert.InRange(motion[covered], 7.99f, 8.01f);
+ Assert.InRange(motion[covered + 1], -8.01f, -7.99f);
+ Assert.InRange(motion[covered + 2], 0.24f, 0.26f);
+ Assert.InRange(motion[covered + 3], 0.49f, 0.51f);
+ Assert.InRange(motion[sky + 2], 0.99f, 1.01f);
+ Assert.InRange(motion[sky + 3], 0.99f, 1.01f);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableTheory]
+ [InlineData(1.7, 0.0)]
+ [InlineData(1.7, -0.35)]
+ [InlineData(25.0, 0.2)]
+ public void StationaryElevatedCameraHasZeroSkyMotion(double height, double pitch)
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ const double near = 0.0689, far = 60.0, fov = 70.0 * Math.PI / 180.0;
+ double[] projection = Mat4d.Perspective(Mat4d.Create(), fov, 1.0, near, far);
+ double[] view = Mat4d.Identity(Mat4d.Create());
+ view = Mat4d.RotateX(view, view, pitch);
+ view = Mat4d.Translate(view, view, 0, -height, 0);
+ double[] vp = Mat4d.Mul(Mat4d.Create(), projection, view);
+ double[] inverse = Mat4d.Invert(Mat4d.Create(), vp);
+ Assert.NotNull(inverse);
+ Assert.True(height / far * (Size / 2.0) / Math.Tan(fov / 2.0) > 0.6);
+ using (device)
+ {
+ var scene = new Scene(device!);
+ (float[] motion, _) = scene.Draw(0f,
+ inverse: Array.ConvertAll(inverse!, v => (float)v),
+ previous: Array.ConvertAll(vp, v => (float)v));
+ foreach ((int x, int y) in new[] { (32, 32), (8, 56), (56, 8), (20, 44) })
+ {
+ int i = (y * Size + x) * 4;
+ Assert.InRange(motion[i], -0.05f, 0.05f);
+ Assert.InRange(motion[i + 1], -0.05f, 0.05f);
+ Assert.InRange(motion[i + 3], 0.99f, 1.01f);
+ }
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private sealed class Scene
+ {
+ private readonly VulkanDevice device;
+ private readonly MotionTarget target;
+ private readonly int program;
+ private readonly int reveal;
+ private readonly int seedProgram;
+ private readonly int seedMesh;
+
+ internal Scene(VulkanDevice device)
+ {
+ this.device = device;
+ ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First(v => v.Name == "taa-no-ssao");
+ Assert.Equal(1, variant.TaaMotion);
+ Assert.Equal(2, variant.TaaMotionLocation);
+ program = LinkFromCorpus(device, ShaderCorpus.BuildProgram("taa-skymotion",
+ ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant), "taa-skymotion", oit: true);
+ Assert.True(device.GetUniformLocation(program, "taaRenderSize") >= 0);
+ target = CreateMotionTarget(device, Size);
+ reveal = device.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8,
+ EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);
+ device.SetTextureParameter(reveal, OptimumGlConstants.TextureMinFilter, 9728);
+ device.SetTextureParameter(reveal, OptimumGlConstants.TextureMagFilter, 9728);
+ int revealTarget = device.CreateFramebuffer(Size, Size);
+ device.AttachTexture(revealTarget, EnumFramebufferAttachment.ColorAttachment0, reveal, 0);
+ device.SetDrawBuffers(revealTarget, 0b1);
+ RevealTarget = revealTarget;
+ const string vertex = "#version 330 core\nlayout(location=0) in vec3 xyz; void main() { gl_Position=vec4(xyz,1); }";
+ const string fragment = "#version 330 core\nlayout(location=2) out vec4 outMotion; void main() { outMotion=vec4(8,-8,0.25,gl_FragCoord.z); }";
+ seedProgram = LinkFromCorpus(device,
+ [
+ new() { Stage = EnumShaderType.VertexShader, Code = vertex, PrefixCode = "", Filename = "sky-seed.vsh" },
+ new() { Stage = EnumShaderType.FragmentShader, Code = fragment, PrefixCode = "", Filename = "sky-seed.fsh" },
+ ], "sky-seed", oit: true);
+ MeshData left = new(4, 6, withNormals: false, withUv: false, withRgba: false, withFlags: false)
+ {
+ xyz = [-1, -1, 0, 0, -1, 0, 0, 1, 0, -1, 1, 0],
+ VerticesCount = 4,
+ Indices = [0, 1, 2, 0, 2, 3],
+ IndicesCount = 6,
+ };
+ seedMesh = device.CreateMesh(left, staticDraw: true);
+ Assert.True(seedMesh > 0, device.GetError() ?? "sky seed upload failed");
+ }
+
+ private int RevealTarget { get; }
+
+ internal (float[] Motion, byte[] Color) Draw(float coverage, float jx = 0, float jy = 0,
+ bool seedLeftHalf = false, float[]? inverse = null, float[]? previous = null)
+ {
+ device.BeginFrame();
+ device.BindFramebuffer(RevealTarget);
+ float revealValue = 1 - coverage;
+ device.ClearColor(0, revealValue, revealValue, revealValue, 1);
+ device.BindFramebuffer(target.Framebuffer);
+ device.SetDrawBuffers(target.Framebuffer, 0b111);
+ device.ClearColor(0, 0.2f, 0.4f, 0.6f, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.SetViewport(0, 0, Size, Size);
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.SetDepthFunc(0x203);
+ device.SetDrawBuffers(target.Framebuffer, 1 << 2);
+ if (seedLeftHalf)
+ {
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.UseProgram(seedProgram);
+ device.DrawMesh(seedMesh);
+ }
+ device.UseProgram(program);
+ device.SetSamplerUnit(program, "transparentRevealTex", 14);
+ device.BindTexture(14, reveal);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", jx, jy);
+ SetMatrix(device, program, "taaInvViewProjJittered", inverse ?? InverseJittered(jx, jy));
+ SetMatrix(device, program, "taaPrevViewProj", previous ?? PreviousViewProjection(Yaw));
+ SetFloat(device, program, "taaCloudReactive", 1);
+ device.SetDepthTest(true);
+ device.SetDepthMask(false);
+ device.DrawFullscreenTriangle();
+ float[] motion = ReadMotion(device, target.MotionTexture, Size);
+ byte[] color = device.ReadBackLevel0ForTests(target.ColorTexture);
+ device.Present();
+ return (motion, color);
+ }
+ }
+}
+
+/// Previous-object motion written by the actual standard shader.
+public sealed class StandardMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private static readonly float[] Identity =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ private static float[] Translation(float x, float y, float z) =>
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ x, y, z, 1,
+ ];
+
+ // At view z = -1 the shear moves the raster position by exactly the
+ // requested subpixel jitter; the previous projection remains unjittered.
+ private static float[] Projection(float jitterX, float jitterY)
+ {
+ var projection = new float[16];
+ projection[0] = projection[5] = 1;
+ projection[8] = -2 * jitterX / Size;
+ projection[9] = -2 * jitterY / Size;
+ projection[10] = projection[11] = projection[14] = -1;
+ return projection;
+ }
+
+ [SkippableFact]
+ public void PreviousModelAndMissingHistoryProduceIndependentPixelVectors()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ Check(scene.Draw(Identity), 0, 0, 0.5f, 0);
+ Check(scene.Draw(Translation(0.25f, 0, 0)), 8, 0, 0.5f, 0);
+ Check(scene.Draw(Translation(0, -0.125f, 0)), 0, -4, 0.5f, 0);
+ Check(scene.Draw(Translation(-0.1875f, 0.0625f, 0)), -6, 2, 0.5f, 0);
+
+ // A stale object transform must not leak into a draw with no history.
+ Check(scene.Draw(Translation(-0.5f, 0.5f, 0), history: false,
+ cameraX: 0.25f, cameraY: -0.125f), 8, -4, 0.5f, 1);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void WarpOptOutAndBehindCameraKeepTheirMotionContract()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ double offset = (Math.Sin(0) + Math.Sin(0.5) + Math.Sin(1) / 3) / 30 * 8;
+ float expected = (float)(offset * Size / 2);
+ Assert.True(expected > 1);
+ Check(scene.Draw(Identity, previousWarp: 8), expected, 0, 0.5f, 0);
+ Check(scene.Draw(Identity, previousWarp: 8, noWarp: true), 0, 0, 0.5f, 0);
+
+ float[] behindProjection =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, -1,
+ 0, 0, 0, 0,
+ ];
+ Check(scene.Draw(Identity, previousView: Translation(0, 0, 1),
+ previousProjection: behindProjection, reactive: 0.6f),
+ 0, 0, 0, 0.6f);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void MoverMotionExcludesProjectionJitterAndIgnoresStaleHistory()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!, perspective: true);
+ Check(scene.Draw(Identity, noWarp: true, jitterX: 0.37f, jitterY: -0.24f),
+ 0, 0, 0.5f, 0);
+ Check(scene.Draw(Identity, noWarp: true, jitterX: -0.5f, jitterY: 0.5f),
+ 0, 0, 0.5f, 0);
+ Check(scene.Draw(Translation(0.25f, 0, 0), noWarp: true,
+ jitterX: 0.5f, jitterY: -0.5f), 8, 0, 0.5f, 0);
+ Check(scene.Draw(Translation(0, -0.125f, 0), noWarp: true,
+ jitterX: -0.5f, jitterY: 0.5f), 0, -4, 0.5f, 0);
+
+ float[] previous = Translation(-0.1875f, 0.0625f, 0);
+ float[] unjittered = scene.Draw(previous, noWarp: true);
+ float[] jittered = scene.Draw(previous, noWarp: true,
+ jitterX: 0.31f, jitterY: 0.47f);
+ Check(jittered, -6, 2, 0.5f, 0);
+ int centre = ((Size / 2) * Size + Size / 2) * 4;
+ Assert.InRange(jittered[centre], unjittered[centre] - 0.05f,
+ unjittered[centre] + 0.05f);
+ Assert.InRange(jittered[centre + 1], unjittered[centre + 1] - 0.05f,
+ unjittered[centre + 1] + 0.05f);
+
+ Check(scene.Draw(Translation(-0.5f, 0.5f, 0), history: false,
+ noWarp: true, cameraX: 0.25f, cameraY: -0.125f,
+ jitterX: 0.42f, jitterY: 0.13f), 8, -4, 0.5f, 1);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private static void Check(float[] pixels, float x, float y, float depth, float reactive)
+ {
+ int centre = ((Size / 2) * Size + Size / 2) * 4;
+ Assert.InRange(pixels[centre], x - 0.05f, x + 0.05f);
+ Assert.InRange(pixels[centre + 1], y - 0.05f, y + 0.05f);
+ Assert.InRange(pixels[centre + 2], reactive - 0.01f, reactive + 0.01f);
+ Assert.InRange(pixels[centre + 3], depth - 0.01f, depth + 0.01f);
+ }
+
+ private sealed class Scene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly MotionTarget target;
+ private readonly int mesh;
+ private readonly bool perspective;
+
+ public Scene(VulkanDevice device, bool perspective = false)
+ {
+ this.device = device;
+ this.perspective = perspective;
+ var variant = new ShaderCorpus.ShaderVariant
+ {
+ Name = "taa-standard",
+ TaaMotion = 1,
+ TaaMotionLocation = 2,
+ };
+ var stages = ShaderCorpus.BuildProgram("standard", ShaderCorpus.LoadShaderFiles(),
+ ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, "standard", oit: false);
+ foreach (string required in new[] { "taaRenderSize", "taaHistoryValid", "prevModelMatrix" })
+ Assert.True(device.GetUniformLocation(program, required) >= 0, required + " is missing");
+ BindEveryDeclaredSampler(device, device, program);
+ target = CreateMotionTarget(device, Size);
+ mesh = CreateFaceMesh(device, perspective ? -1 : 0);
+ }
+
+ public float[] Draw(float[] previousModel, bool history = true, bool noWarp = false,
+ float cameraX = 0, float cameraY = 0, float previousWarp = 0,
+ float[]? previousView = null, float[]? previousProjection = null, float reactive = 0,
+ float jitterX = 0, float jitterY = 0)
+ {
+ device.BeginFrame();
+ device.BindFramebuffer(target.Framebuffer);
+ device.ClearColor(0, 0, 0, 0, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.UseProgram(program);
+
+ SetMatrix(device, program, "projectionMatrix",
+ perspective ? Projection(jitterX, jitterY) : Identity);
+ SetMatrix(device, program, "viewMatrix", Identity);
+ SetMatrix(device, program, "modelMatrix", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixFar", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixNear", Identity);
+ SetMatrix(device, program, "prevProjectionMatrix",
+ previousProjection ?? (perspective ? Projection(0, 0) : Identity));
+ SetMatrix(device, program, "prevViewMatrix", previousView ?? Identity);
+ SetMatrix(device, program, "prevModelMatrix", previousModel);
+ SetInt(device, program, "taaHistoryValid", history ? 1 : 0);
+ SetFloat(device, program, "taaReactive", history ? reactive : 1);
+ SetFloat3(device, program, "cameraPosDelta", cameraX, cameraY, 0);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", jitterX, jitterY);
+ SetInt(device, program, "dontWarpVertices", noWarp ? 1 : 0);
+ SetFloat(device, program, "alphaTest", -1);
+ SetFloat(device, program, "viewDistance", 1024);
+ SetFloat(device, program, "viewDistanceLod0", 1024);
+ SetFloat(device, program, "zNear", 0.1f);
+ SetFloat(device, program, "zFar", 1024);
+ SetFloat(device, program, "shadowRangeFar", 1024);
+ SetFloat(device, program, "shadowRangeNear", 64);
+ SetFloat(device, program, "shadowMapWidthInv", 1);
+ SetFloat(device, program, "shadowMapHeightInv", 1);
+ SetFloat3(device, program, "rgbaAmbientIn", 1, 1, 1);
+ SetFloat4(device, program, "rgbaLightIn", 1, 1, 1, 1);
+ SetFloat4(device, program, "rgbaFogIn", 1, 1, 1, 1);
+ SetFloat4(device, program, "rgbaTint", 1, 1, 1, 1);
+ SetFloat4(device, program, "averageColor", 1, 1, 1, 1);
+ SetFloat2(device, program, "frameSize", Size, Size);
+ SetInt(device, program, "perceptionEffectId", 1);
+ SetInt(device, program, "prevPerceptionEffectId", 1);
+ SetFloat(device, program, "windWaveIntensity", 1);
+ SetFloat(device, program, "waterWaveIntensity", 1);
+ SetFloat(device, program, "prevWindWaveIntensity", 1);
+ SetFloat(device, program, "prevWaterWaveIntensity", 1);
+ SetFloat(device, program, "globalWarpIntensity", 0);
+ SetFloat(device, program, "prevGlobalWarpIntensity", previousWarp);
+
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.SetDepthFunc(0x203); // GL_LEQUAL
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.DrawMesh(mesh);
+ float[] pixels = ReadMotion(device, target.MotionTexture, Size);
+ device.Present();
+ return pixels;
+ }
+ }
+}
+
+/// The instanced shader must read each draw instance's own history.
+public sealed class InstancedMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private static readonly float[] Identity =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ private static float[] Translation(float x, float y, float z) =>
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ x, y, z, 1,
+ ];
+
+ private readonly record struct Instance(float[] Current, float[] Previous,
+ bool History = true, float Reactive = 0);
+
+ [SkippableFact]
+ public void OneDrawKeepsPreviousTransformPerInstance()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ Check(scene.Draw([new Instance(Identity, Identity)]), 32, 0, 0, 0.5f, 0);
+ Check(scene.Draw([new Instance(Identity, Translation(0.25f, 0, 0))]),
+ 32, 8, 0, 0.5f, 0);
+ Check(scene.Draw([new Instance(Identity, Translation(-0.1875f, 0.0625f, 0))]),
+ 32, -6, 2, 0.5f, 0);
+
+ // Two transforms in one draw prove the previous matrix is sourced
+ // from the instance stream rather than a shared uniform.
+ float[] pixels = scene.Draw(
+ [
+ new Instance(Translation(-0.5f, 0, 0), Translation(-0.25f, 0, 0)),
+ new Instance(Translation(0.5f, 0, 0), Translation(0.5f, -0.125f, 0)),
+ ]);
+ Check(pixels, 16, 8, 0, 0.5f, 0);
+ Check(pixels, 48, 0, -4, 0.5f, 0);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void MissingAndBehindCameraHistoryKeepReactiveInformation()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ Check(scene.Draw([new Instance(Identity, Translation(-0.5f, 0.5f, 0),
+ History: false, Reactive: 1)], cameraX: 0.25f, cameraY: -0.125f),
+ 32, 8, -4, 0.5f, 1);
+
+ float[] behindProjection =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, -1,
+ 0, 0, 0, 0,
+ ];
+ Check(scene.Draw([new Instance(Identity, Identity, Reactive: 0.6f)],
+ previousView: Translation(0, 0, 1), previousProjection: behindProjection),
+ 32, 0, 0, 0, 0.6f);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private static void Check(float[] pixels, int x, float expectedX, float expectedY,
+ float depth, float reactive)
+ {
+ int pixel = ((Size / 2) * Size + x) * 4;
+ Assert.InRange(pixels[pixel], expectedX - 0.05f, expectedX + 0.05f);
+ Assert.InRange(pixels[pixel + 1], expectedY - 0.05f, expectedY + 0.05f);
+ Assert.InRange(pixels[pixel + 2], reactive - 0.01f, reactive + 0.01f);
+ Assert.InRange(pixels[pixel + 3], depth - 0.01f, depth + 0.01f);
+ }
+
+ private sealed class Scene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly MotionTarget target;
+
+ public Scene(VulkanDevice device)
+ {
+ this.device = device;
+ var variant = new ShaderCorpus.ShaderVariant
+ {
+ Name = "taa-instanced",
+ TaaMotion = 1,
+ TaaMotionLocation = 2,
+ };
+ var stages = ShaderCorpus.BuildProgram("instanced", ShaderCorpus.LoadShaderFiles(),
+ ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, "instanced", oit: false);
+ foreach (string required in new[] { "taaRenderSize", "prevModelViewMatrix" })
+ Assert.True(device.GetUniformLocation(program, required) >= 0, required + " is missing");
+ BindEveryDeclaredSampler(device, device, program);
+ target = CreateMotionTarget(device, Size);
+ }
+
+ public float[] Draw(Instance[] instances, float cameraX = 0, float cameraY = 0,
+ float[]? previousView = null, float[]? previousProjection = null)
+ {
+ int mesh = device.CreateMesh(BuildMesh(instances), staticDraw: false);
+ Assert.True(mesh > 0, device.GetError() ?? "instanced face upload failed");
+ device.BeginFrame();
+ device.BindFramebuffer(target.Framebuffer);
+ device.ClearColor(0, 0, 0, 0, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.UseProgram(program);
+ SetMatrix(device, program, "projectionMatrix", Identity);
+ SetMatrix(device, program, "modelViewMatrix", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixFar", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixNear", Identity);
+ SetMatrix(device, program, "prevProjectionMatrix", previousProjection ?? Identity);
+ SetMatrix(device, program, "prevModelViewMatrix", previousView ?? Identity);
+ SetFloat3(device, program, "cameraPosDelta", cameraX, cameraY, 0);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", 0, 0);
+ SetFloat(device, program, "alphaTest", -1);
+ SetFloat(device, program, "viewDistance", 1024);
+ SetFloat(device, program, "viewDistanceLod0", 1024);
+ SetFloat(device, program, "zNear", 0.1f);
+ SetFloat(device, program, "zFar", 1024);
+ SetFloat(device, program, "shadowRangeFar", 1024);
+ SetFloat(device, program, "shadowRangeNear", 64);
+ SetFloat(device, program, "shadowMapWidthInv", 1);
+ SetFloat(device, program, "shadowMapHeightInv", 1);
+ SetFloat3(device, program, "rgbaAmbientIn", 1, 1, 1);
+ SetFloat4(device, program, "rgbaFogIn", 1, 1, 1, 1);
+ SetFloat4(device, program, "averageColor", 1, 1, 1, 1);
+ SetFloat2(device, program, "frameSize", Size, Size);
+
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.SetDepthFunc(0x203); // GL_LEQUAL
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.DrawMeshInstanced(mesh, instances.Length);
+ float[] pixels = ReadMotion(device, target.MotionTexture, Size);
+ device.Present();
+ return pixels;
+ }
+
+ private static MeshData BuildMesh(Instance[] instances)
+ {
+ MeshData mesh = CreateFaceData();
+ CustomMeshDataPartFloat stream = OptimumInstanceMotion.CreateInstanceFloats(instances.Length);
+ for (int i = 0; i < instances.Length; i++)
+ {
+ int start = i * OptimumInstanceMotion.InstanceFloats;
+ for (int channel = 0; channel < 4; channel++)
+ stream.Values[start + OptimumInstanceMotion.LightOffset + channel] = 1;
+ Array.Copy(instances[i].Current, 0, stream.Values,
+ start + OptimumInstanceMotion.TransformOffset, 16);
+ Array.Copy(instances[i].Previous, 0, stream.Values,
+ start + OptimumInstanceMotion.PrevTransformOffset, 16);
+ stream.Values[start + OptimumInstanceMotion.MetaOffset] = instances[i].History ? 1 : 0;
+ stream.Values[start + OptimumInstanceMotion.MetaOffset + 1] = instances[i].Reactive;
+ }
+ stream.Count = instances.Length * OptimumInstanceMotion.InstanceFloats;
+ mesh.CustomFloats = stream;
+ return mesh;
+ }
+ }
+}
+
+/// Previous skinning and model motion from the opaque entity shader.
+public sealed class EntityMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private const int AnimationUboBytes = 35 * 16 * sizeof(float);
+ private static readonly float[] Identity =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+
+ private static float[] Translation(float x, float y, float z) =>
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ x, y, z, 1,
+ ];
+
+ [SkippableFact]
+ public void SeparatePreviousBoneAndModelSourcesProduceExpectedPixels()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ Check(new Scene(device!, Identity).Draw(Identity), 0, 0, 0.5f, 0);
+ Check(new Scene(device!, Translation(0.25f, 0, 0)).Draw(Identity),
+ 8, 0, 0.5f, 0);
+ Check(new Scene(device!, Translation(0, -0.125f, 0)).Draw(Identity),
+ 0, -4, 0.5f, 0);
+ Check(new Scene(device!, Translation(-0.1875f, 0.0625f, 0)).Draw(Identity),
+ -6, 2, 0.5f, 0);
+ Check(new Scene(device!, Identity).Draw(Translation(-0.25f, 0.125f, 0)),
+ -8, 4, 0.5f, 0);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void InvalidHistoryWarpAndBehindCameraKeepTheirReactiveContract()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ Check(new Scene(device!, Translation(0.75f, 0.75f, 0)).Draw(
+ Translation(-0.5f, 0.5f, 0), history: false,
+ cameraX: 0.25f, cameraY: -0.125f), 8, -4, 0.5f, 1);
+
+ double offset = (Math.Sin(0) + Math.Sin(0.5) + Math.Sin(1) / 3) / 30 * 8;
+ float expectedWarp = (float)(offset * Size / 2);
+ Assert.True(expectedWarp > 1);
+ Check(new Scene(device!, Identity).Draw(Identity, previousWarp: 8),
+ expectedWarp, 0, 0.5f, 0);
+
+ float[] behindProjection =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, -1,
+ 0, 0, 0, 0,
+ ];
+ Check(new Scene(device!, Identity).Draw(Identity,
+ previousView: Translation(0, 0, 1),
+ previousProjection: behindProjection, reactive: 0.6f),
+ 0, 0, 0, 0.6f);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private static void Check(float[] pixels, float x, float y, float depth, float reactive)
+ {
+ int centre = ((Size / 2) * Size + Size / 2) * 4;
+ Assert.InRange(pixels[centre], x - 0.05f, x + 0.05f);
+ Assert.InRange(pixels[centre + 1], y - 0.05f, y + 0.05f);
+ Assert.InRange(pixels[centre + 2], reactive - 0.01f, reactive + 0.01f);
+ Assert.InRange(pixels[centre + 3], depth - 0.01f, depth + 0.01f);
+ }
+
+ private sealed class Scene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly MotionTarget target;
+ private readonly int mesh;
+
+ public Scene(VulkanDevice device, float[] previousBone)
+ {
+ this.device = device;
+ var variant = new ShaderCorpus.ShaderVariant
+ {
+ Name = "taa-entity-opaque",
+ UseOit = 0,
+ TaaMotion = 1,
+ TaaMotionLocation = 2,
+ MaxAnimatedElements = 35,
+ };
+ var stages = ShaderCorpus.BuildProgram("entityanimated", ShaderCorpus.LoadShaderFiles(),
+ ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, "entityanimated", oit: false);
+ foreach (string required in new[] { "taaRenderSize", "taaHistoryValid" })
+ Assert.True(device.GetUniformLocation(program, required) >= 0, required + " is missing");
+ int unit = BindEveryDeclaredSampler(device, device, program);
+ int atlas = CreateWhiteTexture(device);
+ device.SetSamplerUnit(program, "entityTex", unit);
+ device.BindTexture(unit, atlas);
+
+ int current = device.CreateUniformBuffer(program, 0, "Animation", AnimationUboBytes);
+ int previous = device.CreateUniformBuffer(program, 1, "AnimationPrev", AnimationUboBytes);
+ WriteBone(device, current, Identity);
+ WriteBone(device, previous, previousBone);
+ target = CreateMotionTarget(device, Size);
+ mesh = device.CreateMesh(SkinnedFace(), staticDraw: true);
+ Assert.True(mesh > 0, device.GetError() ?? "entity face upload failed");
+ }
+
+ public float[] Draw(float[] previousModel, bool history = true, float cameraX = 0,
+ float cameraY = 0, float previousWarp = 0, float[]? previousView = null,
+ float[]? previousProjection = null, float reactive = 0)
+ {
+ device.BeginFrame();
+ device.BindFramebuffer(target.Framebuffer);
+ device.ClearColor(0, 0, 0, 0, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.UseProgram(program);
+ SetMatrix(device, program, "projectionMatrix", Identity);
+ SetMatrix(device, program, "viewMatrix", Identity);
+ SetMatrix(device, program, "modelMatrix", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixFar", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixNear", Identity);
+ SetMatrix(device, program, "prevProjectionMatrix", previousProjection ?? Identity);
+ SetMatrix(device, program, "prevViewMatrix", previousView ?? Identity);
+ SetMatrix(device, program, "prevModelMatrix", previousModel);
+ SetInt(device, program, "taaHistoryValid", history ? 1 : 0);
+ SetFloat(device, program, "taaReactive", history ? reactive : 1);
+ SetFloat3(device, program, "cameraPosDelta", cameraX, cameraY, 0);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", 0, 0);
+ SetFloat(device, program, "alphaTest", -1);
+ SetFloat(device, program, "viewDistance", 1024);
+ SetFloat(device, program, "viewDistanceLod0", 1024);
+ SetFloat(device, program, "zNear", 0.1f);
+ SetFloat(device, program, "zFar", 1024);
+ SetFloat(device, program, "shadowRangeFar", 1024);
+ SetFloat(device, program, "shadowRangeNear", 64);
+ SetFloat(device, program, "shadowMapWidthInv", 1);
+ SetFloat(device, program, "shadowMapHeightInv", 1);
+ SetInt(device, program, "entityId", 1);
+ SetFloat3(device, program, "rgbaAmbientIn", 1, 1, 1);
+ SetFloat4(device, program, "rgbaLightIn", 1, 1, 1, 1);
+ SetFloat4(device, program, "rgbaFogIn", 1, 1, 1, 1);
+ SetFloat4(device, program, "renderColor", 1, 1, 1, 1);
+ SetFloat2(device, program, "frameSize", Size, Size);
+ SetInt(device, program, "perceptionEffectId", 1);
+ SetInt(device, program, "prevPerceptionEffectId", 1);
+ SetFloat(device, program, "windWaveIntensity", 1);
+ SetFloat(device, program, "waterWaveIntensity", 1);
+ SetFloat(device, program, "prevWindWaveIntensity", 1);
+ SetFloat(device, program, "prevWaterWaveIntensity", 1);
+ SetFloat(device, program, "globalWarpIntensity", 0);
+ SetFloat(device, program, "prevGlobalWarpIntensity", previousWarp);
+
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.SetDepthFunc(0x203); // GL_LEQUAL
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.DrawMesh(mesh);
+ float[] pixels = ReadMotion(device, target.MotionTexture, Size);
+ device.Present();
+ return pixels;
+ }
+
+ private static unsafe void WriteBone(VulkanDevice device, int buffer, float[] matrix)
+ {
+ fixed (float* values = matrix)
+ device.UpdateUniformBuffer(buffer, (IntPtr)values, 0, 16 * sizeof(float));
+ }
+
+ private static MeshData SkinnedFace()
+ {
+ MeshData mesh = CreateFaceData();
+ mesh.CustomFloats = new CustomMeshDataPartFloat(4)
+ {
+ Count = 4,
+ InterleaveSizes = [1],
+ InterleaveOffsets = [0],
+ InterleaveStride = 4,
+ };
+ mesh.CustomInts = new CustomMeshDataPartInt(4)
+ {
+ Count = 4,
+ InterleaveSizes = [1],
+ InterleaveOffsets = [0],
+ InterleaveStride = 4,
+ };
+ return mesh;
+ }
+ }
+}
+
+/// The liquid velocity pass writes motion without changing shaded colour.
+public sealed class LiquidMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private const float Reactive = 0.3f;
+ private const float LiquidClipW = 1.008f;
+ private static readonly float[] Identity =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+ private static readonly float[] Projection =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, -1, -1,
+ 0, 0, -1, 0,
+ ];
+
+ private static float[] Jittered(float x, float y)
+ {
+ float[] projection = (float[])Projection.Clone();
+ projection[8] -= 2 * x / Size;
+ projection[9] -= 2 * y / Size;
+ return projection;
+ }
+
+ [SkippableFact]
+ public void CameraMotionAndProjectionJitterHaveIndependentPixelExpectations()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new Scene(device!);
+ Check(scene.Draw(), 0, 0, 0.5f, Reactive);
+ Check(scene.Draw(cameraX: 0.25f), 8 / LiquidClipW, 0, 0.5f, Reactive);
+ Check(scene.Draw(cameraY: -0.125f), 0, -4 / LiquidClipW, 0.5f, Reactive);
+ Check(scene.Draw(cameraX: -0.1875f, cameraY: 0.0625f),
+ -6 / LiquidClipW, 2 / LiquidClipW, 0.5f, Reactive);
+ Check(scene.Draw(cameraX: 0.25f, cameraY: -0.125f,
+ jitterX: 0.375f, jitterY: -0.25f),
+ 8 / LiquidClipW, -4 / LiquidClipW, 0.5f, Reactive);
+ Check(scene.Draw(cameraX: 0.25f, cameraY: -0.125f,
+ jitterX: -0.5f, jitterY: 0.5f),
+ 8 / LiquidClipW, -4 / LiquidClipW, 0.5f, Reactive);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void VelocityWindowPreservesEveryColorPixelAndUncoveredMotionDepth()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ Result result = new Scene(device!).Draw(cameraX: 0.25f);
+ Check(result, 8 / LiquidClipW, 0, 0.5f, Reactive);
+ Assert.InRange(Channel(result.Motion, 2, 2, 3), 0, 0.001f);
+ byte[] expected = [51, 102, 153, 255];
+ Assert.Equal(Size * Size * 4, result.Color.Length);
+ for (int pixel = 0; pixel < Size * Size; pixel++)
+ for (int channel = 0; channel < 4; channel++)
+ Assert.InRange(result.Color[pixel * 4 + channel],
+ Math.Max(0, expected[channel] - 1),
+ Math.Min(255, expected[channel] + 1));
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void PreviousWaveAndBehindCameraKeepMotionAndReactiveMeaning()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ Result waves = new Scene(device!, waterFlags: 1).Draw(previousWave: 3);
+ float[] row = new float[24];
+ int moved = 0;
+ for (int i = 0; i < row.Length; i++)
+ {
+ int x = i + 20;
+ Assert.InRange(Channel(waves.Motion, x, 32, 3), 0.48f, 0.52f);
+ Assert.InRange(Channel(waves.Motion, x, 32, 0), -0.05f, 0.05f);
+ row[i] = Channel(waves.Motion, x, 32, 1);
+ if (Math.Abs(row[i]) > 0.3f) moved++;
+ }
+ Assert.True(moved > row.Length / 4, "previous liquid wave wrote no vertical motion");
+ Assert.True(row.Max() - row.Min() > 0.3f, "previous liquid wave lost its noise shape");
+
+ float[] mirrorZ =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, -1, 0,
+ 0, 0, 0, 1,
+ ];
+ Check(new Scene(device!).Draw(previousView: mirrorZ), 0, 0, 0, Reactive);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private sealed record Result(float[] Motion, byte[] Color);
+
+ private static float Channel(float[] pixels, int x, int y, int channel) =>
+ pixels[(y * Size + x) * 4 + channel];
+
+ private static void Check(Result result, float x, float y, float depth, float reactive)
+ {
+ Assert.InRange(Channel(result.Motion, 32, 32, 0), x - 0.06f, x + 0.06f);
+ Assert.InRange(Channel(result.Motion, 32, 32, 1), y - 0.06f, y + 0.06f);
+ Assert.InRange(Channel(result.Motion, 32, 32, 2), reactive - 0.01f, reactive + 0.01f);
+ Assert.InRange(Channel(result.Motion, 32, 32, 3), depth - 0.02f, depth + 0.02f);
+ }
+
+ private sealed class Scene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly MotionTarget target;
+ private readonly int mesh;
+
+ public Scene(VulkanDevice device, int waterFlags = 0)
+ {
+ this.device = device;
+ var variant = ShaderCorpus.Variants().Single(v => v.Name == "taa-no-ssao");
+ Assert.Equal(1, variant.TaaMotion);
+ Assert.Equal(2, variant.TaaMotionLocation);
+ Assert.Equal(1, variant.WavingStuff);
+ var stages = ShaderCorpus.BuildProgram("chunkliquidmotion",
+ ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, "chunkliquidmotion", oit: true);
+ Assert.True(device.GetUniformLocation(program, "taaRenderSize") >= 0);
+ target = CreateMotionTarget(device, Size);
+ mesh = device.CreateMesh(LiquidFace(waterFlags), staticDraw: true);
+ Assert.True(mesh > 0, device.GetError() ?? "liquid face upload failed");
+ }
+
+ public Result Draw(float cameraX = 0, float cameraY = 0, float jitterX = 0,
+ float jitterY = 0, float previousWave = 0, float[]? previousView = null)
+ {
+ device.BeginFrame();
+ device.BindFramebuffer(target.Framebuffer);
+ device.SetDrawBuffers(target.Framebuffer, 0b111);
+ device.ClearColor(0, 0.2f, 0.4f, 0.6f, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.SetDrawBuffers(target.Framebuffer, 1 << 2);
+ device.UseProgram(program);
+ SetMatrix(device, program, "projectionMatrix", Jittered(jitterX, jitterY));
+ SetMatrix(device, program, "modelViewMatrix", Identity);
+ SetMatrix(device, program, "prevProjectionMatrix", Projection);
+ SetMatrix(device, program, "prevModelViewMatrix", previousView ?? Identity);
+ SetFloat3(device, program, "cameraPosDelta", cameraX, cameraY, 0);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", jitterX, jitterY);
+ SetFloat(device, program, "taaLiquidReactive", Reactive);
+ SetInt(device, program, "perceptionEffectId", 1);
+ SetInt(device, program, "prevPerceptionEffectId", 1);
+ SetFloat(device, program, "globalWarpIntensity", 0);
+ SetFloat(device, program, "prevGlobalWarpIntensity", 0);
+ SetFloat(device, program, "windWaveIntensity", 0);
+ SetFloat(device, program, "prevWindWaveIntensity", 0);
+ SetFloat(device, program, "waterWaveIntensity", 0);
+ SetFloat(device, program, "prevWaterWaveIntensity", previousWave);
+ SetFloat(device, program, "prevWaterWaveCounter", 1.234f);
+ SetFloat3(device, program, "origin", 0, 0, 0);
+
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.SetDepthFunc(0x203); // GL_LEQUAL
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.DrawMesh(mesh);
+ float[] motion = ReadMotion(device, target.MotionTexture, Size);
+ device.SetDrawBuffers(target.Framebuffer, 0b111);
+ byte[] color = device.ReadBackLevel0ForTests(target.ColorTexture);
+ device.Present();
+ return new Result(motion, color);
+ }
+
+ private static MeshData LiquidFace(int waterFlags)
+ {
+ MeshData face = CreateFaceData(-1, flags: 0);
+ face.CustomFloats = new CustomMeshDataPartFloat
+ {
+ Values = new float[8],
+ Count = 8,
+ InterleaveOffsets = [0],
+ InterleaveSizes = [2],
+ InterleaveStride = 8,
+ };
+ int[] flags = new int[8];
+ for (int i = 0; i < 4; i++) flags[i * 2 + 1] = waterFlags;
+ face.CustomInts = new CustomMeshDataPartInt
+ {
+ Values = flags,
+ Count = 8,
+ InterleaveOffsets = [0, 4],
+ InterleaveSizes = [1, 1],
+ InterleaveStride = 8,
+ Conversion = DataConversion.Integer,
+ };
+ return face;
+ }
+ }
+}
+
+/// Opaque cube-particle motion and transparent-merge reactive coverage.
+public sealed class ParticleMotionContractTests(ITestOutputHelper output)
+{
+ private const int Size = 64;
+ private static readonly float[] Identity =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1,
+ ];
+ private static readonly float[] Projection =
+ [
+ 1, 0, 0, 0,
+ 0, 1, 0, 0,
+ 0, 0, -1, -1,
+ 0, 0, -1, 0,
+ ];
+
+ private static float[] Jittered(float x, float y)
+ {
+ float[] projection = (float[])Projection.Clone();
+ projection[8] -= 2 * x / Size;
+ projection[9] -= 2 * y / Size;
+ return projection;
+ }
+
+ [SkippableFact]
+ public void CubeParticleWritesCameraMotionReactiveAndColor()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ var scene = new CubeScene(device!);
+ Result still = scene.Draw();
+ Check(still.Motion, 32, 0, 0, 1, 0.5f);
+ int centre = (32 * Size + 32) * 4;
+ byte[] clear = [51, 102, 153, 255];
+ Assert.True(Enumerable.Range(0, 4).Any(c =>
+ Math.Abs(still.Color[centre + c] - clear[c]) > 1),
+ "cube particle did not shade color attachment 0");
+
+ Check(scene.Draw(cameraX: 0.25f).Motion, 32, 8, 0, 1, 0.5f);
+ Check(scene.Draw(cameraY: -0.125f).Motion, 32, 0, -4, 1, 0.5f);
+ Check(scene.Draw(cameraX: -0.1875f, cameraY: 0.0625f).Motion,
+ 32, -6, 2, 1, 0.5f);
+ Check(scene.Draw(cameraX: 0.25f, cameraY: -0.125f,
+ jitterX: 0.375f, jitterY: -0.25f).Motion, 32, 8, -4, 1, 0.5f);
+ Check(scene.Draw(cameraX: 0.25f, cameraY: -0.125f,
+ jitterX: -0.5f, jitterY: 0.5f).Motion, 32, 8, -4, 1, 0.5f);
+ Check(scene.Draw().Motion, 2, 0, 0, 0, 0);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ [SkippableFact]
+ public void TransparentMergeAddsCoverageWithoutReplacingOpaqueVectorOrDepth()
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Skip.IfNot(GpuTest.TryCreateDevice(output, out VulkanDevice? device), "No usable Vulkan device.");
+ using (device)
+ {
+ foreach ((float revealage, float reactive) in new[]
+ {
+ (1f, 0f), (0.25f, 0.75f), (0f, 1f),
+ })
+ Check(new MergeScene(device!).Draw(revealage, 0),
+ 32, 4, -8, reactive, 0.5f);
+
+ Check(new MergeScene(device!).Draw(1, 1), 32, 4, -8, 1, 0.5f);
+ GpuTest.AssertClean(device!);
+ }
+ }
+
+ private sealed record Result(float[] Motion, byte[] Color);
+
+ private static void Check(float[] pixels, int x, float motionX, float motionY,
+ float reactive, float depth)
+ {
+ int pixel = (32 * Size + x) * 4;
+ Assert.InRange(pixels[pixel], motionX - 0.06f, motionX + 0.06f);
+ Assert.InRange(pixels[pixel + 1], motionY - 0.06f, motionY + 0.06f);
+ Assert.InRange(pixels[pixel + 2], reactive - 0.01f, reactive + 0.01f);
+ Assert.InRange(pixels[pixel + 3], depth - 0.02f, depth + 0.02f);
+ }
+
+ private sealed class CubeScene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly MotionTarget target;
+ private readonly int mesh;
+
+ public CubeScene(VulkanDevice device)
+ {
+ this.device = device;
+ var variant = ShaderCorpus.Variants().Single(v => v.Name == "taa-no-ssao");
+ var stages = ShaderCorpus.BuildProgram("particlescube",
+ ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, "particlescube", oit: true);
+ Assert.True(device.GetUniformLocation(program, "taaRenderSize") >= 0);
+ target = CreateMotionTarget(device, Size);
+ mesh = device.CreateMesh(CubeFace(), staticDraw: true);
+ Assert.True(mesh > 0, device.GetError() ?? "cube-particle upload failed");
+ }
+
+ public Result Draw(float cameraX = 0, float cameraY = 0, float jitterX = 0,
+ float jitterY = 0)
+ {
+ device.BeginFrame();
+ device.BindFramebuffer(target.Framebuffer);
+ device.SetDrawBuffers(target.Framebuffer, 0b111);
+ device.ClearColor(0, 0.2f, 0.4f, 0.6f, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+ device.UseProgram(program);
+ SetMatrix(device, program, "projectionMatrix", Jittered(jitterX, jitterY));
+ SetMatrix(device, program, "modelViewMatrix", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixFar", Identity);
+ SetMatrix(device, program, "toShadowMapSpaceMatrixNear", Identity);
+ SetMatrix(device, program, "prevProjectionMatrix", Projection);
+ SetMatrix(device, program, "prevModelViewMatrix", Identity);
+ SetFloat3(device, program, "cameraPosDelta", cameraX, cameraY, 0);
+ SetFloat2(device, program, "taaRenderSize", Size, Size);
+ SetFloat2(device, program, "taaJitterPx", jitterX, jitterY);
+ SetFloat3(device, program, "rgbaAmbientIn", 1, 1, 1);
+ SetInt(device, program, "perceptionEffectId", 1);
+ SetInt(device, program, "prevPerceptionEffectId", 1);
+ SetFloat(device, program, "globalWarpIntensity", 0);
+ SetFloat(device, program, "prevGlobalWarpIntensity", 0);
+
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(true);
+ device.SetDepthMask(true);
+ device.SetDepthFunc(0x203); // GL_LEQUAL
+ device.SetCullFace(false);
+ device.SetBlend(true, EnumBlendMode.Standard);
+ device.SetBlendFuncSeparate(0, 770, 771, 770, 771);
+ device.SetBlendEquation(2, 32774);
+ device.SetBlendFuncSeparate(2, 1, 0, 1, 0);
+ device.DrawMeshInstanced(mesh, 1);
+ float[] motion = ReadMotion(device, target.MotionTexture, Size);
+ byte[] color = device.ReadBackLevel0ForTests(target.ColorTexture);
+ device.Present();
+ return new Result(motion, color);
+ }
+
+ private static MeshData CubeFace()
+ {
+ var mesh = new MeshData(4, 6, withNormals: true, withUv: true,
+ withRgba: false, withFlags: true)
+ {
+ xyz = [-0.5f, -0.5f, 0, 0.5f, -0.5f, 0,
+ 0.5f, 0.5f, 0, -0.5f, 0.5f, 0],
+ Uv = [0, 0, 1, 0, 1, 1, 0, 1],
+ Normals = new int[4],
+ NormalsCount = 4,
+ VerticesCount = 4,
+ Indices = [0, 1, 2, 0, 2, 3],
+ IndicesCount = 6,
+ };
+ mesh.CustomFloats = new CustomMeshDataPartFloat
+ {
+ Instanced = true,
+ StaticDraw = false,
+ Values = [0, 0, -1, 1],
+ Count = 4,
+ InterleaveSizes = [3, 1],
+ InterleaveOffsets = [0, 12],
+ InterleaveStride = 16,
+ };
+ mesh.CustomBytes = new CustomMeshDataPartByte
+ {
+ Conversion = DataConversion.NormalizedFloat,
+ Instanced = true,
+ StaticDraw = false,
+ Values = Enumerable.Repeat((byte)255, 12).ToArray(),
+ Count = 12,
+ InterleaveSizes = [4, 4, 4],
+ InterleaveOffsets = [0, 4, 8],
+ InterleaveStride = 12,
+ };
+ mesh.Flags = new int[4];
+ mesh.FlagsInstanced = true;
+ return mesh;
+ }
+ }
+
+ private sealed class MergeScene
+ {
+ private readonly VulkanDevice device;
+ private readonly int program;
+ private readonly int seedProgram;
+ private readonly MotionTarget target;
+ private readonly int quad;
+ private readonly int accumulation;
+ private readonly int glow;
+ private readonly int oitReveal;
+ private readonly int oitAccumulation;
+
+ public MergeScene(VulkanDevice device)
+ {
+ this.device = device;
+ var variant = ShaderCorpus.Variants().Single(v => v.Name == "taa-no-ssao");
+ var stages = ShaderCorpus.BuildProgram("transparentcompose",
+ ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant);
+ program = LinkFromCorpus(device, stages, "transparentcompose", oit: true);
+ seedProgram = SeedProgram(device);
+ target = CreateMotionTarget(device, Size);
+ quad = FullscreenQuad(device);
+ accumulation = SolidTexture(device, 0, 0, 0, 0);
+ glow = SolidTexture(device, 0, 0, 0, 1);
+ oitReveal = SolidTexture(device, 0, 0, 0, 0);
+ oitAccumulation = device.CreateTexture2DArray(Size, Size, 3,
+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba);
+ }
+
+ public float[] Draw(float revealage, float seedReactive)
+ {
+ int reveal = SolidTexture(device, revealage, 0, 0, 1);
+ device.BeginFrame();
+ device.BindFramebuffer(target.Framebuffer);
+ device.SetDrawBuffers(target.Framebuffer, 0b111);
+ device.ClearColor(0, 0.2f, 0.4f, 0.6f, 1);
+ device.ClearColor(1, 0, 0, 0, 1);
+ device.ClearColor(2, 0, 0, 0, 0);
+ device.ClearDepth(1);
+
+ device.SetDrawBuffers(target.Framebuffer, 1 << 2);
+ device.UseProgram(seedProgram);
+ SetFloat(device, seedProgram, "seedB", seedReactive);
+ device.SetViewport(0, 0, Size, Size);
+ device.SetDepthTest(false);
+ device.SetDepthMask(false);
+ device.SetCullFace(false);
+ device.SetBlend(false, EnumBlendMode.Standard);
+ device.DrawMesh(quad);
+
+ device.SetDrawBuffers(target.Framebuffer, 0b111);
+ device.SetBlend(true, EnumBlendMode.Standard);
+ device.SetBlendFuncSeparate(0, 770, 771, 770, 771);
+ device.SetBlendEquation(2, 32774);
+ device.SetBlendFuncSeparate(2, 1, 1, 1, 1);
+ device.UseProgram(program);
+ Bind("revealage", 10, reveal);
+ Bind("accumulation", 11, accumulation);
+ Bind("inGlow", 12, glow);
+ Bind("OITreveal", 13, oitReveal);
+ Bind("OITaccumulation", 14, oitAccumulation);
+ device.DrawMesh(quad);
+ float[] motion = ReadMotion(device, target.MotionTexture, Size);
+ device.Present();
+ return motion;
+ }
+
+ private void Bind(string sampler, int unit, int texture)
+ {
+ device.SetSamplerUnit(program, sampler, unit);
+ device.BindTexture(unit, texture);
+ }
+
+ private static int SeedProgram(VulkanDevice device)
+ {
+ const string vertex = "#version 330 core\nlayout(location=0) in vec3 xyz;\n"
+ + "void main(){gl_Position=vec4(xyz,1);}";
+ const string fragment = "#version 330 core\nuniform float seedB;\n"
+ + "layout(location=2) out vec4 outMotion;\n"
+ + "void main(){outMotion=vec4(4,-8,seedB,0.5);}";
+ return LinkFromCorpus(device, new List
+ {
+ new() { Stage = EnumShaderType.VertexShader, Code = vertex,
+ Filename = "motion-seed.vsh" },
+ new() { Stage = EnumShaderType.FragmentShader, Code = fragment,
+ Filename = "motion-seed.fsh" },
+ }, "motion-seed", oit: true);
+ }
+
+ private static int FullscreenQuad(VulkanDevice device)
+ {
+ var quad = new MeshData(4, 6, withNormals: false, withUv: false,
+ withRgba: false, withFlags: false)
+ {
+ xyz = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0],
+ VerticesCount = 4,
+ Indices = [0, 1, 2, 0, 2, 3],
+ IndicesCount = 6,
+ };
+ int mesh = device.CreateMesh(quad, staticDraw: true);
+ Assert.True(mesh > 0, device.GetError() ?? "full-screen upload failed");
+ return mesh;
+ }
+
+ private static unsafe int SolidTexture(VulkanDevice device, float r, float g,
+ float b, float a)
+ {
+ byte[] pixels = new byte[Size * Size * 4];
+ byte[] value =
+ [
+ (byte)Math.Clamp((int)MathF.Round(r * 255), 0, 255),
+ (byte)Math.Clamp((int)MathF.Round(g * 255), 0, 255),
+ (byte)Math.Clamp((int)MathF.Round(b * 255), 0, 255),
+ (byte)Math.Clamp((int)MathF.Round(a * 255), 0, 255),
+ ];
+ for (int pixel = 0; pixel < pixels.Length; pixel += 4)
+ value.CopyTo(pixels, pixel);
+ fixed (byte* source = pixels)
+ {
+ int texture = device.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba,
+ (IntPtr)source, false);
+ device.SetTextureParameter(texture, OptimumGlConstants.TextureMinFilter, 9728);
+ device.SetTextureParameter(texture, OptimumGlConstants.TextureMagFilter, 9728);
+ return texture;
+ }
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs
new file mode 100644
index 00000000..31459a13
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/NativeBlitTests.cs
@@ -0,0 +1,447 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using OpenTK.Mathematics;
+using OpenTK.Windowing.Desktop;
+using Optimum.Render.Vulkan.Platform;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.Config;
+using Vintagestory.API.MathTools;
+using Vintagestory.Client;
+using Vintagestory.Client.NoObf;
+using Xunit;
+using Xunit.Abstractions;
+
+// The device integration tests' stand-ins for the client's shader types, under names that do
+// not read as a device construction to the GPU suite's helper-bypass check.
+using LinkedProgram = Optimum.Render.Vulkan.Tests.GpuTest.TestProgram;
+using LinkedShader = Optimum.Render.Vulkan.Tests.GpuTest.TestShader;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// The blit to the Default target, drawn twice on one Vulkan device: through the OpenGL body
+/// (the route every other vanilla system still takes) and through the native device API
+/// (docs/vulkan.md, decision 4). The three branches - TAA debug view,
+/// FSR (EASU into the FSR target, RCAS into Default) and the plain blit - have to produce the
+/// same pixels, and the native one must not touch the GL state tracker, a texture unit or a
+/// draw-buffer mask while its passes are open.
+///
+public class NativeBlitTests(ITestOutputHelper output)
+{
+ private const int WindowSize = 16;
+
+ /// Render resolution below the window: what makes the FSR branch an upsample.
+ private const int RenderSize = 10;
+
+ private static readonly string[] Programs = { "blit", "fsr-easu", "fsr-rcas", "taa-debug" };
+
+ /// The Vulkan platform with FSR under the test's control, so no client setting is read.
+ private sealed class BlitPlatform : VulkanClientPlatform
+ {
+ public BlitPlatform() : base(null!)
+ {
+ }
+
+ public bool FsrActive;
+
+ public override bool OptimumFsrBlitActive() => FsrActive;
+
+ /// No window is opened here, so both routes take the blit's size from this seam.
+ public override Size2i OptimumWindowClientSize() =>
+ new(NativeBlitTests.WindowSize, NativeBlitTests.WindowSize);
+ }
+
+ // ------------------------------------------------------------------ the tests
+
+ ///
+ /// The plain blit: same pixels, one declared pass, one native draw.
+ ///
+ [SkippableFact]
+ public unsafe void ThePlainBlitMatchesTheOpenGlBodyAsOneNativeDraw()
+ {
+ using Session session = Open();
+
+ byte[] stated = RunFrame(session, native: false, debugView: 0, fsr: false);
+
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ long passesBefore = session.Seam.NativePassesForTests;
+ byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: false);
+
+ Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore);
+ Assert.Equal(stated, nativeRoute);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ /// Every TAA debug view draws what the OpenGL body draws, with the same mode and render size.
+ [SkippableTheory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public unsafe void ADebugViewMatchesTheOpenGlBody(int mode)
+ {
+ using Session session = Open();
+
+ byte[] stated = RunFrame(session, native: false, debugView: mode, fsr: false);
+
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ byte[] nativeRoute = RunFrame(session, native: true, debugView: mode, fsr: false);
+
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore);
+ Assert.Equal(stated, nativeRoute);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// FSR at a render scale below 1: EASU upsamples Primary colour 0 into the FSR target and
+ /// RCAS sharpens it into Default - two written targets, two declared passes, and pixels
+ /// the OpenGL body's two draws agree with.
+ ///
+ [SkippableFact]
+ public unsafe void FsrMatchesTheOpenGlBodyWithOnePassPerWrittenTarget()
+ {
+ using Session session = Open();
+
+ byte[] stated = RunFrame(session, native: false, debugView: 0, fsr: true);
+
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ long passesBefore = session.Seam.NativePassesForTests;
+ byte[] nativeRoute = RunFrame(session, native: true, debugView: 0, fsr: true);
+
+ Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore);
+
+ int worst = WorstChannelDifference(stated, nativeRoute);
+ output.WriteLine("FSR worst channel difference: " + worst);
+ Assert.True(worst <= 1, "FSR differs from the OpenGL body by " + worst + "/255");
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ /// The OpenGL body on this device is the generic stated route, and the native route is not.
+ [SkippableFact]
+ public unsafe void TheOpenGlBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot()
+ {
+ using Session session = Open();
+
+ long nativeDrawsBefore = session.Seam.NativeDrawsForTests;
+ long statedBefore = session.Platform.StatedDrawsForTests;
+ RunFrame(session, native: false, debugView: 0, fsr: false);
+ Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore);
+ Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0);
+
+ RunFrame(session, native: true, debugView: 0, fsr: false);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ // ---------------------------------------------------------------------- driving
+
+ private unsafe byte[] RunFrame(Session session, bool native, int debugView, bool fsr)
+ {
+ VulkanDevice seam = session.Seam;
+ session.Platform.NativeBlitEnabled = native;
+ session.Platform.FsrActive = fsr;
+ OptimumConfig.TaaDebugView = debugView;
+
+ session.Platform.BeginFrame();
+
+ // The motion attachment and the depth the debug views read, written as the frame's
+ // own clears so both routes see the identical inputs.
+ seam.BindFramebuffer(session.Primary.FboId);
+ seam.SetDrawBuffers(session.Primary.FboId, 0b111);
+ seam.ClearColor(2, 3f, -5f, 0.25f, 0.5f);
+ seam.ClearDepth(0.5f);
+ seam.SetDrawBuffers(session.Primary.FboId, 0b011);
+
+ seam.BindDefaultFramebuffer();
+ seam.ClearColor(0, 0.125f, 0.75f, 0.375f, 1f);
+
+ // The state ScreenManager.Render leaves the frame in at the blit: blending back on
+ // after the final composition, no depth test, no culling, viewport on the window.
+ seam.SetBlend(true, EnumBlendMode.Standard);
+ seam.SetDepthTest(false);
+ seam.SetCullFace(false);
+ seam.SetViewport(0, 0, WindowSize, WindowSize);
+
+ session.Platform.BlitPrimaryToDefault();
+
+ var pixels = new byte[WindowSize * WindowSize * 4];
+ fixed (byte* destination = pixels)
+ {
+ seam.ReadDefaultFramebuffer(0, 0, WindowSize, WindowSize, (IntPtr)destination);
+ }
+ session.Platform.EndFrame();
+ return pixels;
+ }
+
+ private static int WorstChannelDifference(byte[] a, byte[] b)
+ {
+ Assert.Equal(a.Length, b.Length);
+ int worst = 0;
+ for (int i = 0; i < a.Length; i++) worst = Math.Max(worst, Math.Abs(a[i] - b[i]));
+ return worst;
+ }
+
+ // ---------------------------------------------------------------------- session
+
+ private Session Open()
+ {
+ (string manifest, string reason) = NativeManifest.Value;
+ Skip.If(manifest.Length == 0, reason);
+
+ Session? session = Session.TryOpen(output, manifest);
+ Skip.If(session == null, "No usable Vulkan device.");
+ return session!;
+ }
+
+ ///
+ /// The platform, its device, the frame buffers the blit indexes and the shader programs
+ /// it uses, installed the way the client installs them and put back afterwards.
+ ///
+ private sealed class Session : IDisposable
+ {
+ public BlitPlatform Platform { get; private init; } = null!;
+ public VulkanDevice Seam => Platform.GraphicsDevice!;
+ public FrameBufferRef Primary { get; private set; } = null!;
+
+ private ClientPlatformAbstract? previousPlatform;
+ private string dataPath = "";
+ private ShaderProgramBlit? blitBefore;
+ private ShaderProgram? easuBefore;
+ private ShaderProgram? rcasBefore;
+ private ShaderProgram? debugBefore;
+ private int debugViewBefore;
+
+ public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory)
+ {
+ string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-blit-" + Guid.NewGuid().ToString("N"));
+ var platform = new BlitPlatform
+ {
+ DeviceFactory = () =>
+ {
+ VulkanDevice created = GpuTest.NewDevice();
+ created.NativeShaderDirectory = manifestDirectory;
+ created.NativeShadersEnabled = true;
+ created.IgnoreModShaderScan = true;
+ return created;
+ },
+ CrashMarkerDataPath = dataPath,
+ };
+
+ if (!platform.InitializeGraphics(IntPtr.Zero, WindowSize, WindowSize, out string reason))
+ {
+ output.WriteLine("Vulkan unavailable: " + reason);
+ platform.ShutdownGraphics();
+ return null;
+ }
+
+ var session = new Session
+ {
+ Platform = platform,
+ previousPlatform = ScreenManager.Platform,
+ dataPath = dataPath,
+ blitBefore = ShaderPrograms.Blit,
+ easuBefore = ShaderPrograms.FsrEasu,
+ rcasBefore = ShaderPrograms.FsrRcas,
+ debugBefore = ShaderPrograms.TaaDebug,
+ debugViewBefore = OptimumConfig.TaaDebugView,
+ };
+ ScreenManager.Platform = platform;
+ platform.ShaderUniforms = new DefaultShaderUniforms();
+
+ VulkanDevice seam = platform.GraphicsDevice!;
+ session.Primary = CreatePrimary(seam);
+ FrameBufferRef fsr = CreateFsrTarget(seam);
+ InstallFrameBuffers(platform, session.Primary, fsr);
+
+ var blit = new ShaderProgramBlit { PassName = "blit" };
+ Link(seam, blit, "blit", Array.Empty());
+ var easu = new ShaderProgram { PassName = "fsr-easu" };
+ Link(seam, easu, "fsr-easu", new[] { "inputTexelSize" });
+ var rcas = new ShaderProgram { PassName = "fsr-rcas" };
+ Link(seam, rcas, "fsr-rcas", new[] { "inputTexelSize" });
+ var debug = new ShaderProgram { PassName = "taa-debug" };
+ Link(seam, debug, "taa-debug", new[] { "mode", "renderSize" });
+
+ ShaderPrograms.Blit = blit;
+ ShaderPrograms.FsrEasu = easu;
+ ShaderPrograms.FsrRcas = rcas;
+ ShaderPrograms.TaaDebug = debug;
+ return session;
+ }
+
+ public void Dispose()
+ {
+ ShaderProgramBase.CurrentShaderProgram = null;
+ ShaderPrograms.Blit = blitBefore!;
+ ShaderPrograms.FsrEasu = easuBefore!;
+ ShaderPrograms.FsrRcas = rcasBefore!;
+ ShaderPrograms.TaaDebug = debugBefore!;
+ OptimumConfig.TaaDebugView = debugViewBefore;
+ ScreenManager.Platform = previousPlatform!;
+ Platform.ShutdownGraphics();
+ try
+ {
+ Directory.Delete(dataPath, true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ /// Links one vanilla program as ShaderRegistry does and fills the locations its body looks up.
+ private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms)
+ {
+ List stages = ShaderCorpus.BuildProgram(
+ name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant());
+
+ var linked = new LinkedProgram { PassName = name };
+ foreach (ShaderStageSource stage in stages)
+ {
+ var shader = new LinkedShader
+ {
+ Type = stage.Stage,
+ Code = stage.Code,
+ PrefixCode = stage.PrefixCode,
+ };
+ Assert.True(seam.CompileShader(shader));
+ if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader;
+ else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader;
+ }
+
+ int id = seam.LinkProgram(linked);
+ Assert.True(id > 0, seam.GetError() ?? "link failed");
+ program.ProgramId = id;
+ foreach (string uniform in uniforms)
+ {
+ int location = seam.GetUniformLocation(id, uniform);
+ Assert.True(location != -1, name + " has no location for " + uniform);
+ program.uniformLocations[uniform] = location;
+ }
+ }
+
+ /// Primary at the render resolution: scene, glow and the motion attachment at slot 2, plus depth.
+ private static unsafe FrameBufferRef CreatePrimary(VulkanDevice seam)
+ {
+ var scene = new byte[RenderSize * RenderSize * 4];
+ for (int y = 0; y < RenderSize; y++)
+ {
+ for (int x = 0; x < RenderSize; x++)
+ {
+ int i = (y * RenderSize + x) * 4;
+ scene[i] = (byte)(20 + x * 23);
+ scene[i + 1] = (byte)(40 + y * 17);
+ scene[i + 2] = (byte)(((x ^ y) & 1) * 180 + 30);
+ scene[i + 3] = 255;
+ }
+ }
+
+ int color0;
+ fixed (byte* pixels = scene)
+ {
+ color0 = seam.CreateTexture2D(RenderSize, RenderSize,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)pixels, false);
+ }
+
+ var primary = new FrameBufferRef
+ {
+ Width = RenderSize,
+ Height = RenderSize,
+ FboId = seam.CreateFramebuffer(RenderSize, RenderSize),
+ ColorTextureIds = new[]
+ {
+ color0,
+ seam.CreateTexture2D(RenderSize, RenderSize,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ seam.CreateTexture2D(RenderSize, RenderSize,
+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ },
+ DepthTextureId = seam.CreateTexture2D(RenderSize, RenderSize,
+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false),
+ };
+ for (int slot = 0; slot < primary.ColorTextureIds.Length; slot++)
+ {
+ seam.AttachTexture(primary.FboId,
+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot),
+ primary.ColorTextureIds[slot], 0);
+ }
+ seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0);
+ seam.SetDrawBuffers(primary.FboId, 0b011);
+ Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status);
+ return primary;
+ }
+
+ /// The FSR intermediate at window resolution, as SetupDefaultFrameBuffers builds it.
+ private static FrameBufferRef CreateFsrTarget(VulkanDevice seam)
+ {
+ var target = new FrameBufferRef
+ {
+ Width = WindowSize,
+ Height = WindowSize,
+ FboId = seam.CreateFramebuffer(WindowSize, WindowSize),
+ ColorTextureIds = new[]
+ {
+ seam.CreateTexture2D(WindowSize, WindowSize,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ },
+ };
+ seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0);
+ seam.SetDrawBuffers(target.FboId, 1);
+ return target;
+ }
+
+ /// Primary at slot 0 with its motion attachment at 2, the FSR target at 18, and a window to size the blit by.
+ private static void InstallFrameBuffers(BlitPlatform platform, FrameBufferRef primary, FrameBufferRef fsr)
+ {
+ var list = new List();
+ for (int i = 0; i <= 24; i++) list.Add(null!);
+ list[0] = primary;
+ list[18] = fsr;
+
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list);
+ platform.SetOptimumMotionAttachmentIndex(2);
+ typeof(ClientPlatformWindows).GetField("optimumTaaTargetsReady", flags)!.SetValue(platform, true);
+
+ // No window: OptimumWindowClientSize is the seam both routes size the blit by,
+ // and NativeWindow.ClientSize is GLFW-backed, so a stub could not answer it.
+ }
+ }
+
+ // ---------------------------------------------------------------- native shaders
+
+ /// The manifest of the four programs the blit uses, built once for the whole class.
+ private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders);
+
+ private static (string, string) BuildNativeShaders()
+ {
+ if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason);
+ using (compiler)
+ {
+ var builder = new NativeShaderBuilder(compiler!);
+ var merged = new NativeShaderBuildResult();
+ merged.Manifest.Toolchain = compiler!.Identity;
+ string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk");
+ foreach (string program in Programs)
+ {
+ NativeShaderBuildResult one = builder.Build(source, program);
+ merged.Errors.AddRange(one.Errors);
+ merged.Manifest.Programs.AddRange(one.Manifest.Programs);
+ foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes;
+ }
+ if (!merged.Success) return ("", string.Join("\n", merged.Errors));
+
+ string root = Path.Combine(Path.GetTempPath(), "optimum-native-blit-shaders-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ NativeShaderBuilder.Write(merged, root);
+ return (Path.Combine(root, NativeShaderManifest.DirectoryName), "");
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs
new file mode 100644
index 00000000..b3c70a3b
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/NativeChunkTests.cs
@@ -0,0 +1,637 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Optimum.Render.Vulkan.Platform;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.MathTools;
+using Vintagestory.Client;
+using Vintagestory.Client.NoObf;
+using Xunit;
+using Xunit.Abstractions;
+
+using LinkedProgram = Optimum.Render.Vulkan.Tests.GpuTest.TestProgram;
+using LinkedShader = Optimum.Render.Vulkan.Tests.GpuTest.TestShader;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// The terrain, drawn twice on one Vulkan device: through the stated multi-draw the OpenGL
+/// body takes (NativeChunksEnabled false) and through the native pass
+/// VulkanClientPlatform.NativeChunks.cs records inside a BeginChunkPass / EndChunkPass scope
+/// (docs/vulkan.md, decision 5 stage 2).
+///
+/// Behavioural identity is the acceptance rule (decision 6). The same chunkopaque program, the
+/// same pooled mesh and the same fixed state have to put the same pixels on every attachment of
+/// Primary - the scene, the glow and, with a motion window open, the motion attachment bit for
+/// bit - across the settings that change the chunk passes: blending on and off, culling on and
+/// off, a motion window open and closed, and the motion-only window of the liquid velocity
+/// redraw, whose whole point is that it writes the motion attachment and touches nothing else.
+/// The shadow cascade, which draws a different program into a different target, gets the same
+/// treatment.
+///
+public class NativeChunkTests(ITestOutputHelper output)
+{
+ private const int Size = 32;
+
+ /// The normal-up flags word a solid top face carries (ChunkTerrainRenderTests).
+ private const int UpNormalFlags = 7 << 18;
+
+ /// The platform with no window: both routes take their size from this seam.
+ private sealed class ChunkPlatform : VulkanClientPlatform
+ {
+ public ChunkPlatform() : base(null!)
+ {
+ }
+
+ public override Size2i OptimumWindowClientSize() => new(Size, Size);
+ }
+
+ // ------------------------------------------------------------------------- the tests
+
+ ///
+ /// The opaque terrain group: the native route draws what the stated route draws, on every
+ /// attachment, under each of the blend and cull combinations ChunkRenderer's five Opaque
+ /// groups run with.
+ ///
+ [SkippableTheory]
+ [InlineData("chunk-opaque", true, true)]
+ [InlineData("chunk-vegetation", true, false)]
+ [InlineData("chunk-blendnocull", false, false)]
+ [InlineData("chunk-decorative", true, true)]
+ public void ANativeChunkGroupDrawsWhatTheStatedGroupDraws(string pass, bool blend, bool cull)
+ {
+ using Session session = Open(motion: false);
+
+ byte[][] stated = session.RunGroup(pass, native: false, blend: blend, cull: cull);
+ byte[][] native = session.RunGroup(pass, native: true, blend: blend, cull: cull);
+
+ output.WriteLine("scene centre stated " + Centre(stated[0]) + " native " + Centre(native[0]));
+ Assert.Equal(stated[0], native[0]);
+ Assert.Equal(stated[1], native[1]);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The native route records the group as one declared pass and one indirect multi-draw -
+ /// the shape the chunk path has to keep - and the stated route records neither.
+ ///
+ [SkippableFact]
+ public void TheNativeGroupIsOneDeclaredPassAndOneIndirectMultiDraw()
+ {
+ using Session session = Open(motion: false);
+
+ long passes = session.Seam.NativePassesForTests;
+ long indirect = session.Seam.NativeIndirectDrawsForTests;
+ long draws = session.Seam.NativeDrawsForTests;
+ session.RunGroup("chunk-opaque", native: false, blend: true, cull: true);
+ Assert.Equal(0, session.Seam.NativeDrawsForTests - draws);
+ Assert.Equal(0, session.Seam.NativePassesForTests - passes);
+
+ session.RunGroup("chunk-opaque", native: true, blend: true, cull: true);
+ Assert.Equal(1, session.Seam.NativePassesForTests - passes);
+ Assert.Equal(1, session.Seam.NativeIndirectDrawsForTests - indirect);
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - draws);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// With a motion window open, the motion attachment is bit-identical between the two
+ /// routes: the temporal contract does not change when the group moves to a native pass.
+ ///
+ [SkippableFact]
+ public void TheMotionAttachmentIsIdenticalBetweenTheRoutes()
+ {
+ using Session session = Open(motion: true);
+
+ byte[][] stated = session.RunGroup("chunk-opaque", native: false, blend: true, cull: false, motion: true);
+ byte[][] native = session.RunGroup("chunk-opaque", native: true, blend: true, cull: false, motion: true);
+
+ output.WriteLine("motion centre stated " + Centre(stated[2]) + " native " + Centre(native[2]));
+ Assert.Equal(stated[0], native[0]);
+ Assert.Equal(stated[1], native[1]);
+ Assert.Equal(stated[2], native[2]);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The liquid velocity redraw's window is a colour-write mask, not a draw-buffer toggle:
+ /// the motion attachment takes the draw and the scene and glow attachments keep exactly the
+ /// contents the clear left, on both routes.
+ ///
+ [SkippableFact]
+ public void TheMotionOnlyGroupWritesTheMotionAttachmentAndNothingElse()
+ {
+ using Session session = Open(motion: true);
+
+ byte[][] stated = session.RunGroup("chunk-liquid-motion", native: false, blend: false, cull: false,
+ motion: true, motionOnly: true);
+ byte[][] native = session.RunGroup("chunk-liquid-motion", native: true, blend: false, cull: false,
+ motion: true, motionOnly: true);
+
+ Assert.Equal(stated[0], native[0]);
+ Assert.Equal(stated[1], native[1]);
+ Assert.Equal(stated[2], native[2]);
+
+ // And the mask really is a mask: the shaded slots still hold the clear.
+ Assert.True(IsClear(native[0], Session.SceneClear), "the motion-only group wrote the scene attachment");
+ Assert.True(IsClear(native[1], Session.GlowClear), "the motion-only group wrote the glow attachment");
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The shadow cascade: a different program, a different target, one colour attachment, depth
+ /// written and no blending. Same acceptance - the two routes agree on the depth the cascade
+ /// leaves behind, which is the only thing the shadow map is read for.
+ ///
+ [SkippableFact]
+ public void TheShadowCascadeMatchesBetweenTheRoutes()
+ {
+ using Session session = Open(motion: false);
+
+ byte[] stated = session.RunShadowGroup(native: false);
+ byte[] native = session.RunShadowGroup(native: true);
+
+ Assert.Equal(stated, native);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The group's pipelines are built once and kept: a frame of terrain does not rebuild a
+ /// pipeline per pool, which is what the stated per-draw key resolve used to do.
+ ///
+ [SkippableFact]
+ public void TheGroupBuildsItsPipelinesOnceAndKeepsThem()
+ {
+ using Session session = Open(motion: false);
+
+ session.RunGroup("chunk-opaque", native: true, blend: true, cull: true);
+ int after = session.Seam.NativePipelinesForTests;
+ session.RunGroup("chunk-opaque", native: true, blend: true, cull: true);
+ session.RunGroup("chunk-opaque", native: true, blend: true, cull: true);
+
+ Assert.Equal(after, session.Seam.NativePipelinesForTests);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ // ------------------------------------------------------------------------- driving
+
+ private static string Centre(byte[] pixels)
+ {
+ int i = (Size / 2 * Size + Size / 2) * 4;
+ return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3];
+ }
+
+ private static bool IsClear(byte[] pixels, byte[] clear)
+ {
+ for (int i = 0; i < pixels.Length; i += 4)
+ {
+ for (int c = 0; c < 4; c++)
+ {
+ if (Math.Abs(pixels[i + c] - clear[c]) > 1) return false;
+ }
+ }
+ return true;
+ }
+
+ private Session Open(bool motion)
+ {
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+ Session? session = Session.TryOpen(output, motion);
+ Skip.If(session == null, "No usable Vulkan device.");
+ return session!;
+ }
+
+ ///
+ /// The platform, its device, the Primary target the Opaque stage binds, a shadow map, the
+ /// chunk programs and one pooled terrain mesh, installed the way the client installs them
+ /// and put back afterwards.
+ ///
+ private sealed class Session : IDisposable
+ {
+ public static readonly byte[] SceneClear = { 32, 64, 128, 255 };
+ public static readonly byte[] GlowClear = { 192, 128, 64, 255 };
+
+ public ChunkPlatform Platform { get; private init; } = null!;
+ public VulkanDevice Seam => Platform.GraphicsDevice!;
+ public FrameBufferRef Primary { get; private set; } = null!;
+ public FrameBufferRef Shadow { get; private set; } = null!;
+
+ private ShaderProgram opaque = null!;
+ private ShaderProgram shadowmap = null!;
+ private MeshRef pool = null!;
+ private ClientPlatformAbstract? previousPlatform;
+ private string dataPath = "";
+ private bool motionAttachment;
+
+ /// One pool group: MeshDataPool hands GL's 64-bit byte offsets as int pairs.
+ private static readonly int[] GroupStarts = { 0, 0 };
+ private static readonly int[] GroupSizes = { 6 };
+
+ public static Session? TryOpen(ITestOutputHelper output, bool motion)
+ {
+ string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-chunks-" + Guid.NewGuid().ToString("N"));
+ var platform = new ChunkPlatform
+ {
+ DeviceFactory = () =>
+ {
+ VulkanDevice created = GpuTest.NewDevice();
+ created.IgnoreModShaderScan = true;
+ return created;
+ },
+ CrashMarkerDataPath = dataPath,
+ };
+
+ if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason))
+ {
+ output.WriteLine("Vulkan unavailable: " + reason);
+ platform.ShutdownGraphics();
+ return null;
+ }
+
+ var session = new Session
+ {
+ Platform = platform,
+ previousPlatform = ScreenManager.Platform,
+ dataPath = dataPath,
+ motionAttachment = motion,
+ };
+ ScreenManager.Platform = platform;
+ platform.ShaderUniforms = new DefaultShaderUniforms();
+
+ VulkanDevice seam = platform.GraphicsDevice!;
+ session.Primary = CreatePrimary(seam, motion ? 3 : 2);
+ session.Shadow = CreateShadow(seam);
+ InstallFrameBuffers(platform, session.Primary);
+
+ // The motion attachment is Primary's slot 2 without the SSAO G-buffer, exactly as
+ // SetupDefaultFrameBuffers publishes it.
+ platform.SetOptimumMotionAttachmentIndex(motion ? 2 : -1);
+
+ ShaderCorpus.ShaderVariant variant = ShaderCorpus.Variants().First();
+ variant.TaaMotion = motion ? 1 : 0;
+ variant.TaaMotionLocation = 2;
+ variant.UseSsbo = 0;
+ session.opaque = session.LinkClientProgram(seam, "chunkopaque", variant);
+ session.shadowmap = session.LinkClientProgram(seam, "chunkshadowmap", variant);
+
+ session.pool = platform.UploadMesh(BuildBlockFace());
+ return session;
+ }
+
+ public void Dispose()
+ {
+ ShaderProgramBase.CurrentShaderProgram = null;
+ // The mesh goes first: VAO's finalizer reaches for ScreenManager.Platform, which is
+ // about to be the client's again, and a live handle there would crash the test host.
+ if (pool != null) Platform.DeleteMesh(pool);
+ ScreenManager.Platform = previousPlatform!;
+ Platform.ShutdownGraphics();
+ try
+ {
+ Directory.Delete(dataPath, true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ ///
+ /// One ChunkRenderer draw group, as the client runs it: Primary bound and cleared, the
+ /// GL-shaped state the group sets (which the OpenGL body still needs and the native pass
+ /// ignores), the program's uniforms, then the scope and the pool's multi-draw.
+ ///
+ public byte[][] RunGroup(string pass, bool native, bool blend, bool cull,
+ bool motion = false, bool motionOnly = false)
+ {
+ VulkanDevice seam = Seam;
+ Platform.NativeChunksEnabled = native;
+
+ Platform.BeginFrame();
+ seam.BindFramebuffer(Primary.FboId);
+ seam.SetDrawBuffers(Primary.FboId, (1 << Primary.ColorTextureIds.Length) - 1);
+ Clear(seam);
+
+ Platform.CurrentFrameBuffer = Primary;
+ seam.SetViewport(0, 0, Size, Size);
+ seam.UseProgram(opaque.ProgramId);
+ ShaderProgramBase.CurrentShaderProgram = opaque;
+ SetProgramUniforms(seam, opaque.ProgramId);
+
+ // What BeginMotionWrite / BeginMotionOnlyWrite do once their guards pass: the window
+ // flag, the draw-buffer set the stated route needs, and replace blending on the
+ // motion attachment. The native pass reads the flag and states the rest itself.
+ SetMotionWriteActive(motion);
+ if (motion)
+ {
+ if (motionOnly) Platform.EnableMotionOnlyDrawBuffers();
+ else Platform.EnableMotionDrawBuffers();
+ Platform.ApplyOptimumMotionBlendState();
+ }
+
+ seam.SetDepthTest(true);
+ seam.SetDepthMask(true);
+ seam.SetCullFace(cull);
+ seam.SetBlend(blend, EnumBlendMode.Standard);
+ if (blend) Platform.ApplyOptimumMotionBlendState();
+
+ Platform.BeginChunkPass(pass, blend, depthTest: true, depthWrite: true, cullFace: cull);
+ try
+ {
+ Platform.RenderMesh(pool, GroupStarts, GroupSizes, 1, useSSBOs: false);
+ }
+ finally
+ {
+ Platform.EndChunkPass();
+ }
+
+ if (motion)
+ {
+ Platform.RestorePrimaryDrawBuffers();
+ SetMotionWriteActive(false);
+ }
+
+ var attachments = new byte[Primary.ColorTextureIds.Length][];
+ for (int slot = 0; slot < attachments.Length; slot++)
+ {
+ attachments[slot] = Read(seam, Primary.ColorTextureIds[slot]);
+ }
+ Platform.EndFrame();
+ return attachments;
+ }
+
+ /// One shadow cascade group: the shadow map bound, depth written, no blending.
+ public byte[] RunShadowGroup(bool native)
+ {
+ VulkanDevice seam = Seam;
+ Platform.NativeChunksEnabled = native;
+
+ Platform.BeginFrame();
+ seam.BindFramebuffer(Shadow.FboId);
+ seam.SetDrawBuffers(Shadow.FboId, 1);
+ seam.ClearColor(0, 0f, 0f, 0f, 1f);
+ seam.ClearDepth(1f);
+
+ Platform.CurrentFrameBuffer = Shadow;
+ seam.SetViewport(0, 0, Size, Size);
+ seam.UseProgram(shadowmap.ProgramId);
+ ShaderProgramBase.CurrentShaderProgram = shadowmap;
+ SetProgramUniforms(seam, shadowmap.ProgramId);
+
+ seam.SetDepthMask(true);
+ seam.SetBlend(false, EnumBlendMode.Standard);
+ seam.SetDepthTest(true);
+ seam.SetCullFace(false);
+
+ Platform.BeginChunkPass("chunk-shadow-opaque", blend: false, depthTest: true, depthWrite: true,
+ cullFace: false);
+ try
+ {
+ Platform.RenderMesh(pool, GroupStarts, GroupSizes, 1, useSSBOs: false);
+ }
+ finally
+ {
+ Platform.EndChunkPass();
+ }
+
+ byte[] pixels = Read(seam, Shadow.ColorTextureIds[0]);
+ Platform.EndFrame();
+ return pixels;
+ }
+
+ // ----------------------------------------------------------------- the fixtures
+
+ private void Clear(VulkanDevice seam)
+ {
+ seam.ClearColor(0, SceneClear[0] / 255f, SceneClear[1] / 255f, SceneClear[2] / 255f, 1f);
+ seam.ClearColor(1, GlowClear[0] / 255f, GlowClear[1] / 255f, GlowClear[2] / 255f, 1f);
+ if (Primary.ColorTextureIds.Length > 2) seam.ClearColor(2, 0f, 0f, 0f, 0f);
+ seam.ClearDepth(1f);
+ }
+
+ /// The window flag the platform's guards own; a test opens it directly.
+ private void SetMotionWriteActive(bool active)
+ {
+ if (!motionAttachment) return;
+ typeof(ClientPlatformWindows)
+ .GetField("optimumMotionWriteActive", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .SetValue(Platform, active);
+ }
+
+ ///
+ /// The uniforms a chunk program needs to draw anything (ChunkTerrainRenderTests: without
+ /// the view distances every fragment fades out and the pass draws nothing), plus the
+ /// textures - bound through the platform, because that is the seam the native route
+ /// takes its handles from and the stated route its units.
+ ///
+ private void SetProgramUniforms(VulkanDevice seam, int programId)
+ {
+ float[] identity = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
+ foreach (string name in new[]
+ {
+ "projectionMatrix", "modelViewMatrix", "mvpMatrix",
+ "prevProjectionMatrix", "prevModelViewMatrix",
+ "toShadowMapSpaceMatrixFar", "toShadowMapSpaceMatrixNear",
+ })
+ {
+ int location = seam.GetUniformLocation(programId, name);
+ if (location >= 0) seam.SetUniformMatrix(programId, location, identity);
+ }
+
+ SetFloat(seam, programId, "viewDistance", 1024f);
+ SetFloat(seam, programId, "viewDistanceLod0", 1024f);
+ SetFloat(seam, programId, "alphaTest", 0.001f);
+ SetFloat(seam, programId, "zNear", 0.1f);
+ SetFloat(seam, programId, "zFar", 1024f);
+ SetFloat(seam, programId, "shadowRangeFar", 1024f);
+ SetFloat(seam, programId, "shadowRangeNear", 64f);
+ SetFloat(seam, programId, "shadowMapWidthInv", 1f);
+ SetFloat(seam, programId, "shadowMapHeightInv", 1f);
+ int ambient = seam.GetUniformLocation(programId, "rgbaAmbientIn");
+ if (ambient >= 0) seam.SetUniform(programId, ambient, 1f, 1f, 1f);
+ int frameSize = seam.GetUniformLocation(programId, "frameSize");
+ if (frameSize >= 0) seam.SetUniform(programId, frameSize, (float)Size, (float)Size);
+ }
+
+ private static void SetFloat(VulkanDevice seam, int programId, string name, float value)
+ {
+ int location = seam.GetUniformLocation(programId, name);
+ if (location >= 0) seam.SetUniform(programId, location, value);
+ }
+
+ ///
+ /// Links one vanilla chunk program from the corpus, as ShaderRegistry does, and points
+ /// every sampler it declares at a small gradient through the platform seam - so a
+ /// sampling difference between the routes would show as a pixel difference.
+ ///
+ private unsafe ShaderProgram LinkClientProgram(VulkanDevice seam, string name,
+ ShaderCorpus.ShaderVariant variant)
+ {
+ List stages = ShaderCorpus.BuildProgram(
+ name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant);
+
+ var linked = new LinkedProgram { PassName = name };
+ foreach (ShaderStageSource stage in stages)
+ {
+ var shader = new LinkedShader
+ {
+ Type = stage.Stage,
+ Code = stage.Code,
+ PrefixCode = stage.PrefixCode ?? "",
+ };
+ Assert.True(seam.CompileShader(shader), name + ": " + (seam.GetError() ?? "compile failed"));
+ if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader;
+ else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader;
+ else linked.GeometryShader = shader;
+ }
+
+ int id = seam.LinkProgram(linked);
+ Assert.True(id > 0, name + ": " + (seam.GetError() ?? "link failed"));
+
+ var program = new ShaderProgram { PassName = name, ProgramId = id };
+ int unit = 0;
+ foreach (string sampler in seam.SamplerNamesOf(id))
+ {
+ Platform.BindProgramTexture2D(program, sampler, Gradient(seam, unit), unit);
+ unit++;
+ }
+ return program;
+ }
+
+ /// One attachment's pixels, read through a framebuffer that holds only it.
+ private unsafe byte[] Read(VulkanDevice seam, int texture)
+ {
+ int reader = seam.CreateFramebuffer(Size, Size);
+ seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, texture, 0);
+ seam.SetDrawBuffers(reader, 1);
+ seam.BindFramebuffer(reader);
+
+ var pixels = new byte[Size * Size * 4];
+ fixed (byte* destination = pixels)
+ {
+ seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination);
+ }
+ return pixels;
+ }
+
+ /// Primary as the Opaque stage has it: scene at 0, glow at 1, motion at 2, plus depth.
+ private static FrameBufferRef CreatePrimary(VulkanDevice seam, int colorCount)
+ {
+ var textures = new int[colorCount];
+ for (int slot = 0; slot < colorCount; slot++)
+ {
+ textures[slot] = seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);
+ }
+
+ var primary = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = textures,
+ DepthTextureId = seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent,
+ IntPtr.Zero, false),
+ };
+ Attach(seam, primary);
+ seam.SetDrawBuffers(primary.FboId, (1 << colorCount) - 1);
+ Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status);
+ return primary;
+ }
+
+ /// A shadow cascade's target: one colour attachment and the depth the cascade writes.
+ private static FrameBufferRef CreateShadow(VulkanDevice seam)
+ {
+ var shadow = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = new[]
+ {
+ seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ },
+ DepthTextureId = seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent,
+ IntPtr.Zero, false),
+ };
+ Attach(seam, shadow);
+ seam.SetDrawBuffers(shadow.FboId, 1);
+ Assert.True(seam.CheckFramebufferComplete(shadow.FboId, out string status), status);
+ return shadow;
+ }
+
+ private static void Attach(VulkanDevice seam, FrameBufferRef target)
+ {
+ for (int slot = 0; slot < target.ColorTextureIds.Length; slot++)
+ {
+ seam.AttachTexture(target.FboId,
+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot),
+ target.ColorTextureIds[slot], 0);
+ }
+ seam.AttachTexture(target.FboId, EnumFramebufferAttachment.DepthAttachment, target.DepthTextureId, 0);
+ }
+
+ private static void InstallFrameBuffers(ChunkPlatform platform, FrameBufferRef primary)
+ {
+ var list = new List();
+ for (int i = 0; i <= 24; i++) list.Add(null!);
+ list[0] = primary;
+
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list);
+ }
+
+ /// A small gradient, so a sampling difference between the routes would show.
+ private static unsafe int Gradient(VulkanDevice seam, int phase)
+ {
+ var pixels = new byte[8 * 8 * 4];
+ for (int y = 0; y < 8; y++)
+ {
+ for (int x = 0; x < 8; x++)
+ {
+ int i = (y * 8 + x) * 4;
+ pixels[i] = (byte)(16 + x * 30 + phase * 7);
+ pixels[i + 1] = (byte)(32 + y * 25);
+ pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20);
+ pixels[i + 3] = 255;
+ }
+ }
+ fixed (byte* first = pixels)
+ {
+ return seam.CreateTexture2D(8, 8,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false);
+ }
+ }
+
+ ///
+ /// One tesselated block face in the layout the chunk tesselator emits, covering the
+ /// middle of the target (ChunkTerrainRenderTests.BuildBlockFace).
+ ///
+ private static MeshData BuildBlockFace()
+ {
+ var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true);
+ float[] positions =
+ {
+ -0.5f, -0.5f, 0f,
+ 0.5f, -0.5f, 0f,
+ 0.5f, 0.5f, 0f,
+ -0.5f, 0.5f, 0f,
+ };
+ float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f };
+
+ for (int i = 0; i < 4; i++)
+ {
+ mesh.AddVertexWithFlags(
+ positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2],
+ uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, flags: UpNormalFlags);
+ }
+ foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index);
+ return mesh;
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs
new file mode 100644
index 00000000..b0e41e57
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/NativeEntityDrawTests.cs
@@ -0,0 +1,620 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using Optimum.Render.Vulkan.Platform;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.MathTools;
+using Vintagestory.Client;
+using Vintagestory.Client.NoObf;
+using Xunit;
+using Xunit.Abstractions;
+
+using LinkedProgram = Optimum.Render.Vulkan.Tests.GpuTest.TestProgram;
+using LinkedShader = Optimum.Render.Vulkan.Tests.GpuTest.TestShader;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// An entity's animated shape, drawn twice on one Vulkan device: through the seam's neutral body
+/// (ClientPlatformAbstract.RenderEntityMesh's RenderMesh, the route the OpenGL path takes) and
+/// through the native pass VulkanClientPlatform.RenderEntityMesh records
+/// (docs/vulkan.md, decision 5 stage 2).
+///
+/// Behavioural identity is the acceptance rule (decision 6): the same program, mesh, bone
+/// matrices and fixed state have to put the same pixels on Primary's scene and glow attachments
+/// AND on the motion attachment, which the temporal contract requires to stay bit-identical.
+/// The sweep that matters for this system is the attachment set (with and without the SSAO
+/// G-buffer slots, which changes where the motion attachment sits) and the motion window itself
+/// (TAA on with the window open, and shut), because the window is a colour write mask on the
+/// native route and a draw-buffer toggle on the old one.
+///
+public class NativeEntityDrawTests(ITestOutputHelper output)
+{
+ private const int Size = 16;
+
+ /// The platform with no window: both routes take their size from this seam.
+ private sealed class EntityPlatform : VulkanClientPlatform
+ {
+ public EntityPlatform() : base(null!)
+ {
+ }
+
+ public override Size2i OptimumWindowClientSize() => new(Size, Size);
+ }
+
+ // ------------------------------------------------------------------ the tests
+
+ ///
+ /// Primary as it is without the SSAO G-buffer (scene, glow, motion): the native entity draw
+ /// puts the same pixels on all three attachments as the seam's neutral body, and records nothing else.
+ ///
+ [SkippableTheory]
+ [InlineData(false, true)]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ [InlineData(true, false)]
+ public unsafe void TheNativeEntityDrawMatchesTheSeamsNeutralBody(bool gbuffer, bool motionOpen)
+ {
+ using Session session = Open(gbuffer);
+
+ byte[][] stated = session.RunFrame(native: false, motionOpen);
+
+ long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests;
+ byte[][] native = session.RunFrame(native: true, motionOpen);
+
+ Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore);
+
+ for (int slot = 0; slot < stated.Length; slot++)
+ {
+ output.WriteLine("slot " + slot + " stated " + Centre(stated[slot]) +
+ " native " + Centre(native[slot]));
+ Assert.Equal(stated[slot], native[slot]);
+ }
+ // An identity comparison of two blank attachments proves nothing: the shape has to have
+ // reached the scene slot.
+ Assert.NotEqual(session.ClearOf(0), Centre(native[0]));
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// Phase 3b decision 1: a mod renderer stays on the adapter. VSEssentials registers its own
+ /// entityanimated for the first-person hands, and that program drew the arm wrong through the
+ /// native route with TAA on; the route therefore takes only the registered vanilla programs.
+ ///
+ [SkippableFact]
+ public void AModRegisteredEntityProgramStaysOnTheNeutralBody()
+ {
+ using Session session = Open(gbuffer: false);
+ session.UnregisterVanillaProgram();
+
+ long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests;
+ byte[][] drawn = session.RunFrame(native: true, motionOpen: true);
+
+ Assert.Equal(0, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore);
+ Assert.NotEqual(session.ClearOf(0), Centre(drawn[0]));
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The motion attachment is the one the temporal contract pins: with the window open the two
+ /// routes write the same vectors, and with it shut neither route touches it, so whatever was
+ /// there survives. A native pipeline that forgot the write mask would fail the second half.
+ ///
+ [SkippableFact]
+ public unsafe void TheMotionAttachmentIsIdenticalBetweenTheRoutesAndUntouchedWithTheWindowShut()
+ {
+ using Session session = Open(gbuffer: false);
+
+ byte[] statedOpen = session.RunFrame(native: false, motionOpen: true)[Session.MotionSlot];
+ byte[] nativeOpen = session.RunFrame(native: true, motionOpen: true)[Session.MotionSlot];
+ Assert.Equal(statedOpen, nativeOpen);
+
+ // With the window shut the attachment is out of the draw-buffer set on the old route and
+ // masked out of the pipeline on the native one, so both leave the frame's clear standing.
+ // That is rule 9 in its narrowest form: an attachment nothing writes must not pick up
+ // whatever Vulkan would otherwise leave in it.
+ byte[] statedShut = session.RunFrame(native: false, motionOpen: false)[Session.MotionSlot];
+ byte[] nativeShut = session.RunFrame(native: true, motionOpen: false)[Session.MotionSlot];
+ Assert.Equal(statedShut, nativeShut);
+ Assert.Equal(session.ClearOf(Session.MotionSlot), Centre(nativeShut));
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The seam's neutral body draws through the generic stated route and the native route does not:
+ /// the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes.
+ ///
+ [SkippableFact]
+ public unsafe void TheNeutralBodyDrawsThroughTheStatedRouteAndTheNativeRouteDoesNot()
+ {
+ using Session session = Open(gbuffer: false);
+
+ long nativeDrawsBefore = session.Seam.NativeDrawsForTests;
+ session.RunFrame(native: false, motionOpen: true);
+ Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore);
+
+ session.RunFrame(native: true, motionOpen: true);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The pipeline is built once per (program, target, mesh shape, motion window) and kept, and
+ /// opening or shutting the window is a different pipeline - the write mask is baked into it
+ /// rather than toggled through a draw-buffer set.
+ ///
+ [SkippableFact]
+ public unsafe void TheEntityPassKeepsItsPipelineAndOneMoreForTheOtherMotionWindow()
+ {
+ using Session session = Open(gbuffer: false);
+
+ session.RunFrame(native: true, motionOpen: true);
+ int afterOpen = session.Seam.NativePipelinesForTests;
+ session.RunFrame(native: true, motionOpen: true);
+ Assert.Equal(afterOpen, session.Seam.NativePipelinesForTests);
+
+ session.RunFrame(native: true, motionOpen: false);
+ Assert.Equal(afterOpen + 1, session.Seam.NativePipelinesForTests);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The animation block is read the same way by both routes. The client uploads the pose once
+ /// per entity per frame through UBO.Update("Animation", ...); the device snapshots it into
+ /// the frame's uniform ring per (frame, version) and the draw resolves it when it binds set 2
+ /// with its own mesh id. The native draw adds nothing to that - it re-uploads nothing and
+ /// carries no bone data in its push block - so the two routes have to produce the same image
+ /// for the same pose, and a pose uploaded before the first draw has to be the one that stands
+ /// for every later draw of that program.
+ ///
+ /// What this does NOT prove, and is recorded rather than asserted: this harness could not make
+ /// the pose observable in the image. A session posed with a translated bone renders the same
+ /// pixels as one left at identity, on BOTH routes and with the native manifest variant linked
+ /// (blocks u:Animation@1, u:AnimationPrev@2). Because both routes agree, it says nothing about
+ /// this port; it is a question about the animation block's feed on the native shader path, or
+ /// about this fixture, and it wants a look in the game.
+ ///
+ [SkippableFact]
+ public unsafe void TheAnimationBlockIsReadTheSameWayByBothRoutes()
+ {
+ using Session session = Open(gbuffer: false);
+ session.PoseJoint(shiftX: 0.5f);
+
+ // Warm-up: the native pipeline compiles in the background and its first draws are skipped
+ // until it is published, exactly as on the stated route.
+ session.RunFrame(native: true, motionOpen: true);
+ byte[] posed = session.RunFrame(native: true, motionOpen: true)[0];
+ byte[] posedStated = session.RunFrame(native: false, motionOpen: true)[0];
+
+ Assert.NotEqual(session.ClearOf(0), Centre(posed));
+ Assert.Equal(posed, posedStated);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ // ---------------------------------------------------------------------- driving
+
+ private static string Centre(byte[] pixels)
+ {
+ int i = (Size / 2 * Size + Size / 2) * 4;
+ return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3];
+ }
+
+ private Session Open(bool gbuffer)
+ {
+ (string manifest, string reason) = NativeManifest.Value;
+ Skip.If(manifest.Length == 0, reason);
+
+ Session? session = Session.TryOpen(output, manifest, gbuffer);
+ Skip.If(session == null, "No usable Vulkan device.");
+ return session!;
+ }
+
+ ///
+ /// The platform, its device, the Primary target the Opaque stage binds, the entityanimated
+ /// program with its Animation storage block, and one entity mesh - installed the way the
+ /// client installs them and put back afterwards.
+ ///
+ private sealed class Session : IDisposable
+ {
+ /// The motion attachment is the last colour slot of Primary, as SetupDefaultFrameBuffers appends it.
+ public const int MotionSlot = 2;
+
+ private static readonly float[] Identity =
+ { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
+
+ public EntityPlatform Platform { get; private init; } = null!;
+ public VulkanDevice Seam => Platform.GraphicsDevice!;
+ public FrameBufferRef Primary { get; private set; } = null!;
+
+ /// The colour RunFrame clears a slot to, as the readback reports it.
+ public string ClearOf(int slot)
+ {
+ var clear = new[]
+ {
+ (byte)Math.Round(0.1f * (slot + 1) * 255f),
+ (byte)Math.Round(0.2f * 255f),
+ (byte)Math.Round(0.3f * 255f),
+ (byte)255,
+ };
+ return clear[0] + "," + clear[1] + "," + clear[2] + "," + clear[3];
+ }
+
+ private MeshRef shape = null!;
+ private ShaderProgram entity = null!;
+ private ShaderProgramEntityanimated? previousEntityProgram;
+
+ /// What a mod-registered entityanimated looks like to the route: not the registered vanilla program.
+ public void UnregisterVanillaProgram() => ShaderPrograms.Entityanimated = new ShaderProgramEntityanimated();
+ private UBORef animation = null!;
+ private UBORef animationPrev = null!;
+ private readonly float[] bones = new float[16 * 4];
+ private int atlas;
+ private bool gbuffer;
+ private ClientPlatformAbstract? previousPlatform;
+ private string dataPath = "";
+
+ private static readonly FieldInfo MotionWriteActive =
+ typeof(ClientPlatformWindows).GetField("optimumMotionWriteActive",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory, bool gbuffer)
+ {
+ string dataPath = Path.Combine(Path.GetTempPath(),
+ "optimum-native-entity-" + Guid.NewGuid().ToString("N"));
+ var platform = new EntityPlatform
+ {
+ DeviceFactory = () =>
+ {
+ VulkanDevice created = GpuTest.NewDevice();
+ created.NativeShaderDirectory = manifestDirectory;
+ created.NativeShadersEnabled = true;
+ created.IgnoreModShaderScan = true;
+ return created;
+ },
+ CrashMarkerDataPath = dataPath,
+ };
+
+ if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason))
+ {
+ output.WriteLine("Vulkan unavailable: " + reason);
+ platform.ShutdownGraphics();
+ return null;
+ }
+
+ var session = new Session
+ {
+ Platform = platform,
+ previousPlatform = ScreenManager.Platform,
+ dataPath = dataPath,
+ gbuffer = gbuffer,
+ };
+ ScreenManager.Platform = platform;
+ platform.ShaderUniforms = new DefaultShaderUniforms();
+
+ VulkanDevice seam = platform.GraphicsDevice!;
+ session.Primary = CreatePrimary(seam, gbuffer);
+ InstallFrameBuffers(platform, session.Primary);
+ // Where SetupDefaultFrameBuffers put the motion attachment: after the shaded set.
+ platform.SetOptimumMotionAttachmentIndex(session.Primary.ColorTextureIds.Length - 1);
+
+ // The vanilla program type, registered where the client registers it: the native route
+ // takes vanilla entity programs only, and a mod program under the same pass name stays
+ // on the neutral body (AModRegisteredEntityProgramStaysOnTheNeutralBody).
+ var program = new ShaderProgramEntityanimated { PassName = "entityanimated" };
+ Link(seam, program, "entityanimated", Variant(gbuffer), new[]
+ {
+ "modelMatrix", "viewMatrix", "projectionMatrix",
+ "rgbaLightIn", "rgbaAmbientIn", "renderColor", "alphaTest",
+ });
+ session.entity = program;
+ session.previousEntityProgram = ShaderPrograms.Entityanimated;
+ ShaderPrograms.Entityanimated = program;
+
+ session.atlas = Gradient(seam);
+ // The two animation blocks ShaderProgramEntityanimated creates for the opaque
+ // program: the pose and, with TAA on, the previous pose the motion writer reads.
+ session.animation = platform.CreateUBO(program.ProgramId, 0, "Animation", 16 * 4 * sizeof(float));
+ platform.BindUBO((UBO)session.animation);
+ session.animationPrev = platform.CreateUBO(program.ProgramId, 1, "AnimationPrev", 16 * 4 * sizeof(float));
+ platform.BindUBO((UBO)session.animationPrev);
+ session.PoseJoint(shiftX: 0f);
+
+ session.shape = platform.UploadMesh(BuildShape());
+ return session;
+ }
+
+ public void Dispose()
+ {
+ ShaderProgramBase.CurrentShaderProgram = null;
+ ShaderPrograms.Entityanimated = previousEntityProgram!;
+ if (shape != null) Platform.DeleteMesh(shape);
+ ScreenManager.Platform = previousPlatform!;
+ Platform.ShutdownGraphics();
+ try
+ {
+ Directory.Delete(dataPath, true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ ///
+ /// The bone matrices, written the way EntityShapeRenderer writes them - through
+ /// UBO.Update on the "Animation" block, once per entity per frame. Nothing about the
+ /// native route changes this: the device's storage ring snapshots it per (frame,
+ /// version), and the draw only has to bind the set with the right mesh id.
+ ///
+ public void PoseJoint(float shiftX)
+ {
+ for (int joint = 0; joint < 4; joint++)
+ {
+ Identity.CopyTo(bones, joint * 16);
+ bones[joint * 16 + 12] = shiftX;
+ }
+ GCHandle pinned = GCHandle.Alloc(bones, GCHandleType.Pinned);
+ try
+ {
+ Platform.UpdateUBO((UBO)animation, pinned.AddrOfPinnedObject(), 0,
+ bones.Length * sizeof(float), false);
+ Platform.UpdateUBO((UBO)animationPrev, pinned.AddrOfPinnedObject(), 0,
+ bones.Length * sizeof(float), false);
+ }
+ finally
+ {
+ pinned.Free();
+ }
+ }
+
+ ///
+ /// One frame of the Opaque stage at the point the batched entity loop runs: Primary bound
+ /// and cleared, the stage's own state set, the program's uniforms and texture bound, the
+ /// motion window in the state under test, then the seam.
+ ///
+ public unsafe byte[][] RunFrame(bool native, bool motionOpen)
+ {
+ VulkanDevice seam = Seam;
+ Platform.NativeEntitiesEnabled = native;
+
+ Platform.BeginFrame();
+ int slots = Primary.ColorTextureIds.Length;
+ uint shadedMask = (1u << (slots - 1)) - 1u;
+ seam.BindFramebuffer(Primary.FboId);
+ seam.SetDrawBuffers(Primary.FboId, (int)(motionOpen ? (1u << slots) - 1u : shadedMask));
+ for (int slot = 0; slot < slots; slot++)
+ {
+ seam.ClearColor(slot, 0.1f * (slot + 1), 0.2f, 0.3f, 1f);
+ }
+ seam.ClearDepth(1f);
+
+ Platform.CurrentFrameBuffer = Primary;
+ seam.SetViewport(0, 0, Size, Size);
+ // What SystemRenderEntities.OnRenderOpaque3D sets before its batched loop.
+ seam.SetDepthTest(true);
+ seam.SetDepthMask(true);
+ seam.SetCullFace(false);
+ seam.SetBlend(true, EnumBlendMode.Standard);
+
+ seam.UseProgram(entity.ProgramId);
+ ShaderProgramBase.CurrentShaderProgram = entity;
+ foreach (string uniform in new[] { "modelMatrix", "viewMatrix", "projectionMatrix" })
+ {
+ seam.SetUniformMatrix(entity.ProgramId, entity.uniformLocations[uniform], Identity);
+ }
+ // Lit white with no tint, so the shape actually lands on the attachments: with the
+ // record left at zero the fragment's alpha is zero and the alpha blend the stage set
+ // keeps the clear, which would make an identity comparison vacuous.
+ entity.Uniform("rgbaLightIn", 1f, 1f, 1f, 1f);
+ entity.Uniform("rgbaAmbientIn", 1f, 1f, 1f);
+ entity.Uniform("renderColor", 1f, 1f, 1f, 1f);
+ entity.Uniform("alphaTest", 0.001f);
+ // The client's own sampler declaration: both routes see the same texture, the
+ // stated one through the unit and the native one through the declared name.
+ Platform.BindProgramTexture2D(entity, "entityTex", atlas, 0);
+
+ MotionWriteActive.SetValue(Platform, motionOpen);
+ // The blend state the motion window forces on its attachment, for the old route.
+ Platform.ApplyOptimumMotionBlendState();
+
+ Platform.RenderEntityMesh(shape, "entityTex", atlas);
+
+ MotionWriteActive.SetValue(Platform, false);
+
+ var attachments = new byte[slots][];
+ for (int slot = 0; slot < slots; slot++)
+ {
+ attachments[slot] = Read(seam, Primary.ColorTextureIds[slot]);
+ }
+ Platform.EndFrame();
+ return attachments;
+ }
+
+ /// One attachment's pixels, read through a framebuffer that holds only it.
+ private unsafe byte[] Read(VulkanDevice seam, int texture)
+ {
+ int reader = seam.CreateFramebuffer(Size, Size);
+ seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, texture, 0);
+ seam.SetDrawBuffers(reader, 1);
+ seam.BindFramebuffer(reader);
+
+ var pixels = new byte[Size * Size * 4];
+ fixed (byte* destination = pixels)
+ {
+ seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination);
+ }
+ seam.BindFramebuffer(Primary.FboId);
+ return pixels;
+ }
+
+ // ----------------------------------------------------------------- fixtures
+
+ ///
+ /// The opaque entity program as ShaderRegistry builds it for this client: not the OIT
+ /// copy, with the TAA motion writer compiled in at the slot the framebuffer put it, and
+ /// the G-buffer varyings when the SSAO attachments are there.
+ ///
+ private static ShaderCorpus.ShaderVariant Variant(bool gbuffer) => new()
+ {
+ UseOit = 0,
+ TaaMotion = 1,
+ TaaMotionLocation = gbuffer ? 4 : 2,
+ SsaoLevel = gbuffer ? 1 : 0,
+ };
+
+ private static void Link(VulkanDevice seam, ShaderProgramBase program, string name,
+ ShaderCorpus.ShaderVariant variant, string[] uniforms)
+ {
+ List stages = ShaderCorpus.BuildProgram(
+ name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant);
+
+ var linked = new LinkedProgram { PassName = name };
+ foreach (ShaderStageSource stage in stages)
+ {
+ var shader = new LinkedShader
+ {
+ Type = stage.Stage,
+ Code = stage.Code,
+ PrefixCode = stage.PrefixCode,
+ };
+ Assert.True(seam.CompileShader(shader));
+ if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader;
+ else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader;
+ }
+
+ int id = seam.LinkProgram(linked);
+ Assert.True(id > 0, seam.GetError() ?? "link failed");
+ program.ProgramId = id;
+ foreach (string uniform in uniforms)
+ {
+ int location = seam.GetUniformLocation(id, uniform);
+ Assert.True(location != -1, name + " has no location for " + uniform);
+ program.uniformLocations[uniform] = location;
+ }
+ }
+
+ ///
+ /// Primary as the Opaque stage has it: scene at 0, glow at 1, the SSAO G-buffer's normal
+ /// and position at 2 and 3 when it is on, and the motion attachment appended after them.
+ ///
+ private static FrameBufferRef CreatePrimary(VulkanDevice seam, bool gbuffer)
+ {
+ int shaded = gbuffer ? 4 : 2;
+ var colors = new int[shaded + 1];
+ for (int slot = 0; slot < colors.Length; slot++)
+ {
+ colors[slot] = seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);
+ }
+
+ var primary = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = colors,
+ DepthTextureId = seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.DepthComponent32, EnumTexturePixelFormat.DepthComponent,
+ IntPtr.Zero, false),
+ };
+ for (int slot = 0; slot < colors.Length; slot++)
+ {
+ seam.AttachTexture(primary.FboId,
+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot),
+ colors[slot], 0);
+ }
+ seam.AttachTexture(primary.FboId, EnumFramebufferAttachment.DepthAttachment, primary.DepthTextureId, 0);
+ seam.SetDrawBuffers(primary.FboId, (int)((1u << colors.Length) - 1u));
+ Assert.True(seam.CheckFramebufferComplete(primary.FboId, out string status), status);
+ return primary;
+ }
+
+ private static void InstallFrameBuffers(EntityPlatform platform, FrameBufferRef primary)
+ {
+ var list = new List();
+ for (int i = 0; i <= 24; i++) list.Add(null!);
+ list[0] = primary;
+
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list);
+ }
+
+ /// A small gradient atlas, so a sampling difference between the routes would show.
+ private static unsafe int Gradient(VulkanDevice seam)
+ {
+ var pixels = new byte[8 * 8 * 4];
+ for (int y = 0; y < 8; y++)
+ {
+ for (int x = 0; x < 8; x++)
+ {
+ int i = (y * 8 + x) * 4;
+ pixels[i] = (byte)(16 + x * 30);
+ pixels[i + 1] = (byte)(32 + y * 25);
+ pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20);
+ pixels[i + 3] = 255;
+ }
+ }
+ fixed (byte* first = pixels)
+ {
+ return seam.CreateTexture2D(8, 8,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false);
+ }
+ }
+
+ ///
+ /// The shape as entityanimated sees it: positions, UVs, a per-vertex colour and render
+ /// flags, reduced to two triangles that cover enough of the target for every attachment
+ /// to be comparable. damageEffectIn and jointId are left to the layout's constant
+ /// defaults - jointId 0, the joint PoseJoint moves - which is exactly the GL promise
+ /// VertexLayoutDescription.WithDefaultsFor keeps for an attribute a mesh does not carry.
+ ///
+ private static MeshData BuildShape()
+ {
+ var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: true);
+ float[] positions =
+ {
+ -0.7f, -0.7f, 0.5f,
+ 0.7f, -0.7f, 0.5f,
+ 0.7f, 0.7f, 0.5f,
+ -0.7f, 0.7f, 0.5f,
+ };
+ float[] uvs = { 0, 0, 1, 0, 1, 1, 0, 1 };
+ for (int i = 0; i < 4; i++)
+ {
+ mesh.AddVertexWithFlags(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2],
+ uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, 0);
+ }
+ foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index);
+ return mesh;
+ }
+ }
+
+ // ---------------------------------------------------------------- native shaders
+
+ /// The entityanimated program's manifest, built once for the whole class.
+ private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders);
+
+ private static (string, string) BuildNativeShaders()
+ {
+ if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason);
+ using (compiler)
+ {
+ var builder = new NativeShaderBuilder(compiler!);
+ var merged = new NativeShaderBuildResult();
+ merged.Manifest.Toolchain = compiler!.Identity;
+ string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk");
+ NativeShaderBuildResult one = builder.Build(source, "entityanimated");
+ merged.Errors.AddRange(one.Errors);
+ merged.Manifest.Programs.AddRange(one.Manifest.Programs);
+ foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes;
+ if (!merged.Success) return ("", string.Join("\n", merged.Errors));
+
+ string root = Path.Combine(Path.GetTempPath(),
+ "optimum-native-entity-shaders-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ NativeShaderBuilder.Write(merged, root);
+ return (Path.Combine(root, NativeShaderManifest.DirectoryName), "");
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs
new file mode 100644
index 00000000..3748da08
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/NativeGuiTests.cs
@@ -0,0 +1,643 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using Optimum.Render.Vulkan.Platform;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.MathTools;
+using Vintagestory.Client;
+using Vintagestory.Client.NoObf;
+using Xunit;
+using Xunit.Abstractions;
+
+using LinkedProgram = Optimum.Render.Vulkan.Tests.GpuTest.TestProgram;
+using LinkedShader = Optimum.Render.Vulkan.Tests.GpuTest.TestShader;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// The two GUI and text systems that draw through the native device API, each drawn twice on
+/// one Vulkan device: through the seam's neutral body (the OpenGL body's RenderMesh, the route
+/// every system that has not moved still takes) and through the native pass
+/// VulkanClientPlatform records (docs/vulkan.md, decision 5 stage 2).
+///
+/// - the texture-into-texture blit, which bakes every Cairo-drawn GUI and text surface into a
+/// texture (ClientMain.RenderTextureIntoFrameBuffer, the texture2texture program);
+/// - the aiming reticle's line draws (SystemRenderPlayerAimAcc, the gui program with noTexture
+/// set), which are the first native draws with line topology and a caller-chosen line width.
+///
+/// Behavioural identity is the acceptance rule (decision 6): the same shader, the same mesh and
+/// the same fixed state have to put the same pixels on the target, with blending on and off and
+/// at every line width the callers ask for, and the native route must not touch the GL state
+/// tracker, a texture unit or a draw-buffer mask while its pass is open.
+///
+public class NativeGuiTests(ITestOutputHelper output)
+{
+ private const int Size = 16;
+
+ /// The platform with no window: both routes take their size from this seam.
+ private sealed class GuiPlatform : VulkanClientPlatform
+ {
+ public GuiPlatform() : base(null!)
+ {
+ }
+
+ public override Size2i OptimumWindowClientSize() => new(Size, Size);
+ }
+
+ // ------------------------------------------------------------------ the tests
+
+ ///
+ /// The native texture-blit pass draws what the seam's neutral body draws, with blending on
+ /// (the alphaTest >= 0 case, which is every Cairo bake) and with it off: one declared pass,
+ /// one native mesh draw, and the same pixels.
+ ///
+ [SkippableTheory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public unsafe void TheNativeTextureBlitMatchesTheSeamsNeutralBody(bool blend)
+ {
+ using Session session = Open();
+
+ byte[] stated = session.RunTextureQuad(native: false, blend);
+
+ long passesBefore = session.Seam.NativePassesForTests;
+ long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests;
+ byte[] native = session.RunTextureQuad(native: true, blend);
+
+ Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore);
+
+ output.WriteLine("blit centre stated " + Centre(stated) + " native " + Centre(native));
+ Assert.Equal(stated, native);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The native line overlay draws what the seam's neutral body draws, at both widths the
+ /// aiming reticle asks for - 0.5 for the accuracy rectangle and 1 for the crosshair lines.
+ /// The clamp to the device's lineWidthRange is on both routes, so a driver whose minimum is
+ /// 1.0 rasterizes the 0.5 draw the same way through either.
+ ///
+ [SkippableTheory]
+ [InlineData(0.5f)]
+ [InlineData(1.0f)]
+ public unsafe void TheNativeLineOverlayMatchesTheSeamsNeutralBody(float lineWidth)
+ {
+ using Session session = Open();
+
+ byte[] stated = session.RunOverlayLines(native: false, lineWidth);
+
+ long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests;
+ byte[] native = session.RunOverlayLines(native: true, lineWidth);
+
+ Assert.Equal(1, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore);
+
+ output.WriteLine("line row stated " + Row(stated) + " native " + Row(native));
+ Assert.Equal(stated, native);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The seams' neutral bodies draw through the generic stated route and the native route does
+ /// not: the switch is real, and "OFF is vanilla" holds for the route the OpenGL path takes.
+ ///
+ [SkippableFact]
+ public unsafe void TheNeutralBodiesDrawThroughTheStatedRouteAndTheNativeRouteDoesNot()
+ {
+ using Session session = Open();
+
+ long nativeDrawsBefore = session.Seam.NativeDrawsForTests;
+ long statedBefore = session.Platform.StatedDrawsForTests;
+ session.RunTextureQuad(native: false, blend: true);
+ session.RunOverlayLines(native: false, 1.0f);
+ Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore);
+ Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0);
+
+ session.RunTextureQuad(native: true, blend: true);
+ session.RunOverlayLines(native: true, 1.0f);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The two systems are two pipelines and stay two, frame after frame - a Cairo bake that
+ /// happens hundreds of times in a frame must not build a pipeline per call - and the line
+ /// width is part of the pipeline's identity, so the reticle's 0.5 and 1.0 draws are two
+ /// entries rather than one entry drawn twice at whichever width came last.
+ ///
+ [SkippableFact]
+ public unsafe void TheGuiPassesKeepTheirPipelinesAndSeparateTheLineWidths()
+ {
+ using Session session = Open();
+
+ session.RunTextureQuad(native: true, blend: true);
+ session.RunOverlayLines(native: true, 1.0f);
+ int afterBoth = session.Seam.NativePipelinesForTests;
+
+ session.RunTextureQuad(native: true, blend: true);
+ session.RunOverlayLines(native: true, 1.0f);
+ Assert.Equal(afterBoth, session.Seam.NativePipelinesForTests);
+
+ // A different line width is a different pipeline, not the same one re-emitted.
+ session.RunOverlayLines(native: true, 0.5f);
+ Assert.Equal(afterBoth + 1, session.Seam.NativePipelinesForTests);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// A Cairo bake is a fresh texture every time. Twenty of them through the native route must
+ /// cost twenty bindless slot resolutions out of the frame's own arena and still exactly one
+ /// pipeline: the per-frame descriptor churn the stage brief warns about has to land in the
+ /// arena, not in a permanent descriptor per texture.
+ ///
+ [SkippableFact]
+ public unsafe void EveryFreshTextureResolvesIntoTheFrameArenaAndBuildsNoNewPipeline()
+ {
+ using Session session = Open();
+
+ session.RunTextureQuad(native: true, blend: true);
+ int pipelines = session.Seam.NativePipelinesForTests;
+
+ var textures = new List();
+ for (int i = 0; i < 20; i++) textures.Add(session.NewGradient(i + 3));
+
+ long drawsBefore = session.Seam.NativeMeshDrawsForTests;
+ foreach (int texture in textures) session.RunTextureQuad(native: true, blend: true, texture);
+
+ Assert.Equal(textures.Count, session.Seam.NativeMeshDrawsForTests - drawsBefore);
+ Assert.Equal(pipelines, session.Seam.NativePipelinesForTests);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// An atlas composition samples the texture it writes (BlendedTextureManager copies one atlas
+ /// region into another region of the same atlas). The native pass takes the same pooled
+ /// ReadSelf copy the stated route takes, instead of refusing the draw: two native mesh
+ /// draws, the same pixels, validation clean.
+ ///
+ [SkippableFact]
+ public unsafe void TheNativeTextureBlitReadsItsOwnTargetThroughACopy()
+ {
+ using Session session = Open();
+
+ byte[] stated = session.RunSelfBlit(native: false);
+
+ long meshDrawsBefore = session.Seam.NativeMeshDrawsForTests;
+ byte[] native = session.RunSelfBlit(native: true);
+
+ Assert.Equal(2, session.Seam.NativeMeshDrawsForTests - meshDrawsBefore);
+
+ output.WriteLine("self blit row stated " + Row(stated) + " native " + Row(native));
+ Assert.Equal(stated, native);
+ // The right half is the copied left half, not the clear colour.
+ int left = (Size / 2 * Size + 1) * 4;
+ int right = (Size / 2 * Size + Size / 2 + 1) * 4;
+ Assert.Equal(stated[left + 1], stated[right + 1]);
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ // ---------------------------------------------------------------------- driving
+
+ private static string Centre(byte[] pixels)
+ {
+ int i = (Size / 2 * Size + Size / 2) * 4;
+ return pixels[i] + "," + pixels[i + 1] + "," + pixels[i + 2] + "," + pixels[i + 3];
+ }
+
+ /// A whole row through the middle, where the overlay's line lands.
+ private static string Row(byte[] pixels)
+ {
+ var text = new System.Text.StringBuilder();
+ for (int x = 0; x < Size; x++)
+ {
+ int i = (Size / 2 * Size + x) * 4;
+ text.Append(pixels[i]).Append(':').Append(pixels[i + 3]).Append(' ');
+ }
+ return text.ToString();
+ }
+
+ private Session Open()
+ {
+ (string manifest, string reason) = NativeManifest.Value;
+ Skip.If(manifest.Length == 0, reason);
+
+ Session? session = Session.TryOpen(output, manifest);
+ Skip.If(session == null, "No usable Vulkan device.");
+ return session!;
+ }
+
+ ///
+ /// The platform, its device, the target the GUI draws into, both programs and both meshes,
+ /// installed the way the client installs them and put back afterwards.
+ ///
+ private sealed class Session : IDisposable
+ {
+ private static readonly float[] Identity =
+ { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };
+
+ public GuiPlatform Platform { get; private init; } = null!;
+ public VulkanDevice Seam => Platform.GraphicsDevice!;
+ public FrameBufferRef Target { get; private set; } = null!;
+ public MeshRef Quad { get; private set; } = null!;
+ public MeshRef Lines { get; private set; } = null!;
+ public int SourceTexture { get; private set; }
+
+ private ShaderProgram blit = null!;
+ private ShaderProgram gui = null!;
+ private ClientPlatformAbstract? previousPlatform;
+ private string dataPath = "";
+
+ public static unsafe Session? TryOpen(ITestOutputHelper output, string manifestDirectory)
+ {
+ string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-gui-" + Guid.NewGuid().ToString("N"));
+ var platform = new GuiPlatform
+ {
+ DeviceFactory = () =>
+ {
+ VulkanDevice created = GpuTest.NewDevice();
+ created.NativeShaderDirectory = manifestDirectory;
+ created.NativeShadersEnabled = true;
+ created.IgnoreModShaderScan = true;
+ return created;
+ },
+ CrashMarkerDataPath = dataPath,
+ };
+
+ if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason))
+ {
+ output.WriteLine("Vulkan unavailable: " + reason);
+ platform.ShutdownGraphics();
+ return null;
+ }
+
+ var session = new Session
+ {
+ Platform = platform,
+ previousPlatform = ScreenManager.Platform,
+ dataPath = dataPath,
+ };
+ ScreenManager.Platform = platform;
+ platform.ShaderUniforms = new DefaultShaderUniforms();
+
+ VulkanDevice seam = platform.GraphicsDevice!;
+ session.Target = CreateTarget(seam);
+ InstallFrameBuffers(platform, session.Target);
+
+ var blitProgram = new ShaderProgram { PassName = "texture2texture" };
+ Link(seam, blitProgram, "texture2texture",
+ new[] { "xs", "ys", "width", "height", "texu", "texv", "texw", "texh", "alphaTest" });
+ session.blit = blitProgram;
+
+ var guiProgram = new ShaderProgram { PassName = "gui" };
+ Link(seam, guiProgram, "gui",
+ new[] { "projectionMatrix", "modelViewMatrix", "rgbaIn", "noTexture", "applyColor", "alphaTest" });
+ session.gui = guiProgram;
+
+ session.SourceTexture = Gradient(seam, 0);
+ BindSamplerUnits(seam, blitProgram, session.SourceTexture);
+ BindSamplerUnits(seam, guiProgram, session.SourceTexture);
+
+ session.Quad = platform.UploadMesh(BuildQuad());
+ session.Lines = platform.UploadMesh(BuildLines());
+ return session;
+ }
+
+ ///
+ /// The units the client's program setters bind: what the stated route resolves its
+ /// samplers through. The native route passes the handles instead.
+ ///
+ private static void BindSamplerUnits(VulkanDevice seam, ShaderProgramBase program, int texture)
+ {
+ string[] names = seam.SamplerNamesOf(program.ProgramId);
+ for (int i = 0; i < names.Length; i++)
+ {
+ int unit = program.uniformLocations.Count + i;
+ seam.SetSamplerUnit(program.ProgramId, names[i], unit);
+ // Only the first sampler ever carries a texture here: texture2texture has one,
+ // and gui's overlay sampler is unused while noTexture is 1, which is exactly the
+ // reticle's case - so both routes see nothing bound for it.
+ seam.BindTexture(unit, i == 0 ? texture : 0);
+ }
+ }
+
+ public void Dispose()
+ {
+ ShaderProgramBase.CurrentShaderProgram = null;
+ // The meshes go first: VAO's finalizer reaches for ScreenManager.Platform, which is
+ // about to be the client's again, and a live handle there would crash the test host.
+ if (Quad != null) Platform.DeleteMesh(Quad);
+ if (Lines != null) Platform.DeleteMesh(Lines);
+ ScreenManager.Platform = previousPlatform!;
+ Platform.ShutdownGraphics();
+ try
+ {
+ Directory.Delete(dataPath, true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ /// A fresh source texture, as a Cairo bake produces one per surface.
+ public unsafe int NewGradient(int phase) => Gradient(Seam, phase);
+
+ ///
+ /// One frame at the point RenderTextureIntoFrameBuffer reaches its draw: the
+ /// destination framebuffer bound and cleared, the depth test off, blending exactly as
+ /// that method's alphaTest decided, the program's uniforms set, then the seam.
+ ///
+ public unsafe byte[] RunTextureQuad(bool native, bool blend, int textureId = 0)
+ {
+ VulkanDevice seam = Seam;
+ Platform.NativeGuiEnabled = native;
+ int source = textureId != 0 ? textureId : SourceTexture;
+
+ Platform.BeginFrame();
+ BeginTarget(seam);
+ seam.SetBlend(blend, EnumBlendMode.Standard);
+
+ seam.UseProgram(blit.ProgramId);
+ ShaderProgramBase.CurrentShaderProgram = blit;
+ // The destination rectangle covers the whole target and the source rectangle the
+ // whole texture: RenderTextureIntoFrameBuffer's own normalisation, at its limits.
+ Set(seam, blit, "xs", 0f);
+ Set(seam, blit, "ys", 0f);
+ Set(seam, blit, "width", 1f);
+ Set(seam, blit, "height", 1f);
+ Set(seam, blit, "texu", 0f);
+ Set(seam, blit, "texv", 0f);
+ Set(seam, blit, "texw", 1f);
+ Set(seam, blit, "texh", 1f);
+ Set(seam, blit, "alphaTest", blend ? 0.005f : -1f);
+ if (textureId != 0) seam.BindTexture(blit.uniformLocations.Count, textureId);
+
+ Platform.RenderTextureQuad(Quad, source, blend);
+
+ byte[] pixels = Read(seam);
+ Platform.EndFrame();
+ return pixels;
+ }
+
+ ///
+ /// One frame of an atlas composition: the gradient blitted over the whole target, then
+ /// the target's left half blitted into its right half with the target's own texture as
+ /// the source, blending off (BlendedTextureManager's base copy).
+ ///
+ public unsafe byte[] RunSelfBlit(bool native)
+ {
+ VulkanDevice seam = Seam;
+ Platform.NativeGuiEnabled = native;
+
+ Platform.BeginFrame();
+ BeginTarget(seam);
+ seam.SetBlend(false, EnumBlendMode.Standard);
+ seam.UseProgram(blit.ProgramId);
+ ShaderProgramBase.CurrentShaderProgram = blit;
+
+ SetRects(seam, 0f, 1f, 0f, 1f);
+ seam.BindTexture(blit.uniformLocations.Count, SourceTexture);
+ Platform.RenderTextureQuad(Quad, SourceTexture, false);
+
+ int own = Target.ColorTextureIds[0];
+ SetRects(seam, 0.5f, 0.5f, 0f, 0.5f);
+ seam.BindTexture(blit.uniformLocations.Count, own);
+ Platform.RenderTextureQuad(Quad, own, false);
+ seam.BindTexture(blit.uniformLocations.Count, SourceTexture);
+
+ byte[] pixels = Read(seam);
+ Platform.EndFrame();
+ return pixels;
+ }
+
+ private void SetRects(VulkanDevice seam, float xs, float width, float texu, float texw)
+ {
+ Set(seam, blit, "xs", xs);
+ Set(seam, blit, "ys", 0f);
+ Set(seam, blit, "width", width);
+ Set(seam, blit, "height", 1f);
+ Set(seam, blit, "texu", texu);
+ Set(seam, blit, "texv", 0f);
+ Set(seam, blit, "texw", texw);
+ Set(seam, blit, "texh", 1f);
+ Set(seam, blit, "alphaTest", -1f);
+ }
+
+ ///
+ /// One frame at the point SystemRenderPlayerAimAcc reaches one of its draws: the gui
+ /// program current with noTexture set, blending on, the line width it just chose.
+ ///
+ public unsafe byte[] RunOverlayLines(bool native, float lineWidth)
+ {
+ VulkanDevice seam = Seam;
+ Platform.NativeGuiEnabled = native;
+
+ Platform.BeginFrame();
+ BeginTarget(seam);
+ seam.SetBlend(true, EnumBlendMode.Standard);
+ seam.SetLineWidth(lineWidth);
+
+ seam.UseProgram(gui.ProgramId);
+ ShaderProgramBase.CurrentShaderProgram = gui;
+ seam.SetUniformMatrix(gui.ProgramId, gui.uniformLocations["projectionMatrix"], Identity);
+ seam.SetUniformMatrix(gui.ProgramId, gui.uniformLocations["modelViewMatrix"], Identity);
+ seam.SetUniform(gui.ProgramId, gui.uniformLocations["rgbaIn"], 1f, 0.5f, 0.25f, 1f);
+ Set(seam, gui, "noTexture", 1f);
+ seam.SetUniform(gui.ProgramId, gui.uniformLocations["applyColor"], 0);
+ Set(seam, gui, "alphaTest", 0f);
+
+ Platform.RenderOverlayLines(Lines, 0, lineWidth, blend: true);
+
+ byte[] pixels = Read(seam);
+ Platform.EndFrame();
+ return pixels;
+ }
+
+ private static void Set(VulkanDevice seam, ShaderProgramBase program, string name, float value) =>
+ seam.SetUniform(program.ProgramId, program.uniformLocations[name], value);
+
+ /// The target bound, cleared and put into the state both GUI systems draw in.
+ private void BeginTarget(VulkanDevice seam)
+ {
+ seam.BindFramebuffer(Target.FboId);
+ seam.SetDrawBuffers(Target.FboId, 1);
+ seam.ClearColor(0, 0.1f, 0.2f, 0.3f, 1f);
+
+ Platform.CurrentFrameBuffer = Target;
+ seam.SetViewport(0, 0, Size, Size);
+ seam.SetDepthTest(false);
+ seam.SetDepthMask(false);
+ seam.SetCullFace(false);
+ }
+
+ private unsafe byte[] Read(VulkanDevice seam)
+ {
+ int reader = seam.CreateFramebuffer(Size, Size);
+ seam.AttachTexture(reader, EnumFramebufferAttachment.ColorAttachment0, Target.ColorTextureIds[0], 0);
+ seam.SetDrawBuffers(reader, 1);
+ seam.BindFramebuffer(reader);
+
+ var pixels = new byte[Size * Size * 4];
+ fixed (byte* destination = pixels)
+ {
+ seam.ReadDefaultFramebuffer(0, 0, Size, Size, (IntPtr)destination);
+ }
+ seam.BindFramebuffer(Target.FboId);
+ return pixels;
+ }
+
+ // ----------------------------------------------------------------- fixtures
+
+ /// Links one vanilla program as ShaderRegistry does and fills the locations the test sets.
+ private static void Link(VulkanDevice seam, ShaderProgramBase program, string name, string[] uniforms)
+ {
+ List stages = ShaderCorpus.BuildProgram(
+ name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), new ShaderCorpus.ShaderVariant());
+
+ var linked = new LinkedProgram { PassName = name };
+ foreach (ShaderStageSource stage in stages)
+ {
+ var shader = new LinkedShader
+ {
+ Type = stage.Stage,
+ Code = stage.Code,
+ PrefixCode = stage.PrefixCode,
+ };
+ Assert.True(seam.CompileShader(shader));
+ if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader;
+ else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader;
+ }
+
+ int id = seam.LinkProgram(linked);
+ Assert.True(id > 0, seam.GetError() ?? "link failed");
+ program.ProgramId = id;
+ foreach (string uniform in uniforms)
+ {
+ int location = seam.GetUniformLocation(id, uniform);
+ Assert.True(location != -1, name + " has no location for " + uniform);
+ program.uniformLocations[uniform] = location;
+ }
+ }
+
+ ///
+ /// The destination a Cairo bake writes into and the default framebuffer the Ortho stage
+ /// draws into have the same shape here: one colour attachment, no depth.
+ ///
+ private static FrameBufferRef CreateTarget(VulkanDevice seam)
+ {
+ var target = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = new[]
+ {
+ seam.CreateTexture2D(Size, Size,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ },
+ };
+ seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0);
+ seam.SetDrawBuffers(target.FboId, 1);
+ Assert.True(seam.CheckFramebufferComplete(target.FboId, out string status), status);
+ return target;
+ }
+
+ private static void InstallFrameBuffers(GuiPlatform platform, FrameBufferRef target)
+ {
+ var list = new List();
+ for (int i = 0; i <= 24; i++) list.Add(null!);
+ list[0] = target;
+
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic;
+ typeof(ClientPlatformWindows).GetField("frameBuffers", flags)!.SetValue(platform, list);
+ }
+
+ /// A small gradient, so a sampling difference between the routes would show.
+ private static unsafe int Gradient(VulkanDevice seam, int phase)
+ {
+ var pixels = new byte[8 * 8 * 4];
+ for (int y = 0; y < 8; y++)
+ {
+ for (int x = 0; x < 8; x++)
+ {
+ int i = (y * 8 + x) * 4;
+ pixels[i] = (byte)(16 + x * 30 + phase * 7);
+ pixels[i + 1] = (byte)(32 + y * 25);
+ pixels[i + 2] = (byte)(((x + y) & 1) * 200 + 20);
+ pixels[i + 3] = (byte)(96 + ((x + y) & 3) * 40);
+ }
+ }
+ fixed (byte* first = pixels)
+ {
+ return seam.CreateTexture2D(8, 8,
+ EnumTextureInternalFormat.Rgba8, EnumTexturePixelFormat.Rgba, (IntPtr)first, false);
+ }
+ }
+
+ /// The unit quad ClientMain keeps for its 2D draws: positions and UVs.
+ private static MeshData BuildQuad()
+ {
+ var mesh = new MeshData(4, 6, withNormals: false, withUv: true, withRgba: true, withFlags: false);
+ float[] positions =
+ {
+ -1f, -1f, 0f,
+ 1f, -1f, 0f,
+ 1f, 1f, 0f,
+ -1f, 1f, 0f,
+ };
+ float[] uvs = { 0f, 0f, 1f, 0f, 1f, 1f, 0f, 1f };
+ for (int i = 0; i < 4; i++)
+ {
+ mesh.AddVertexWithFlags(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2],
+ uvs[i * 2], uvs[i * 2 + 1], ColorUtil.WhiteArgb, 0);
+ }
+ foreach (int index in new[] { 0, 1, 2, 0, 2, 3 }) mesh.AddIndex(index);
+ return mesh;
+ }
+
+ ///
+ /// One line across the middle, in the shape SystemRenderPlayerAimAcc tesselates its
+ /// reticle in: EnumDrawMode.Lines, positions and a per-vertex colour, no textures.
+ ///
+ private static MeshData BuildLines()
+ {
+ var mesh = new MeshData(2, 2, withNormals: false, withUv: true, withRgba: true, withFlags: false);
+ mesh.SetMode(EnumDrawMode.Lines);
+ mesh.AddVertexWithFlags(-0.8f, 0f, 0f, 0f, 0f, ColorUtil.WhiteArgb, 0);
+ mesh.AddVertexWithFlags(0.8f, 0f, 0f, 1f, 0f, ColorUtil.WhiteArgb, 0);
+ mesh.AddIndex(0);
+ mesh.AddIndex(1);
+ return mesh;
+ }
+ }
+
+ // ---------------------------------------------------------------- native shaders
+
+ /// The two programs' manifest, built once for the whole class.
+ private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders);
+
+ private static (string, string) BuildNativeShaders()
+ {
+ if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason);
+ using (compiler)
+ {
+ var builder = new NativeShaderBuilder(compiler!);
+ var merged = new NativeShaderBuildResult();
+ merged.Manifest.Toolchain = compiler!.Identity;
+ string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk");
+ foreach (string program in new[] { "texture2texture", "gui" })
+ {
+ NativeShaderBuildResult one = builder.Build(source, program);
+ merged.Errors.AddRange(one.Errors);
+ merged.Manifest.Programs.AddRange(one.Manifest.Programs);
+ foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes;
+ }
+ if (!merged.Success) return ("", string.Join("\n", merged.Errors));
+
+ string root = Path.Combine(Path.GetTempPath(), "optimum-native-gui-shaders-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ NativeShaderBuilder.Write(merged, root);
+ return (Path.Combine(root, NativeShaderManifest.DirectoryName), "");
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs
new file mode 100644
index 00000000..01e194e4
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/NativePostChainTests.cs
@@ -0,0 +1,1560 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using Optimum.Render.Vulkan.Platform;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Vintagestory.API.Common;
+using Vintagestory.API.Config;
+using Vintagestory.API.MathTools;
+using Vintagestory.Client;
+using Vintagestory.Client.NoObf;
+using Xunit;
+using Xunit.Abstractions;
+
+using LinkedProgram = Optimum.Render.Vulkan.Tests.GpuTest.TestProgram;
+using LinkedShader = Optimum.Render.Vulkan.Tests.GpuTest.TestShader;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// The post and TAA chain on the Vulkan platform (docs/vulkan.md, stage 1).
+/// Optimum owns the chain's order; its first two passes - the OIT merge and sky motion - draw
+/// through the native device API, and the rest run the OpenGL body until their own stage moves
+/// them.
+///
+/// What is asserted here:
+/// 1. the OIT merge draws the same pixels as the OpenGL body, with and without the motion
+/// window, into the shaded image, the glow attachment and the motion attachment;
+/// 2. sky motion writes the same motion attachment as the OpenGL body and leaves the shaded
+/// image alone;
+/// 3. each native pass records only its own draws (structural since the GL emulation went);
+/// 4. over several frames the chain runs its steps in the declared order and the TAA resolve
+/// keeps accumulating - the history parity alternates and the motion attachment the resolve
+/// reads was written by the two passes that run before it;
+/// 5. the TAA resolve and the TAA sharpen draw the same pixels natively as on the OpenGL body,
+/// from a cold history and from a warm one, and the sharpen is skipped on both routes when
+/// there is nothing to sharpen;
+/// 6. several frames of the native resolve with no readback between them keep accumulating -
+/// the result after five frames is not the result after one, and it is the OpenGL body's.
+///
+public class NativePostChainTests(ITestOutputHelper output)
+{
+ private const int Size = 16;
+
+ private static readonly string[] Programs =
+ {
+ "transparentcompose", "taa-skymotion", "taa-resolve", "taa-sharpen", "blit",
+ "findbright", "blur", "godrays", "luma", "final",
+ };
+
+ ///
+ /// A jitter phase per loop step, pinned so two runs of the same length see the same sequence
+ /// whatever the global frame counter happens to be. The values are Halton-shaped: sub-pixel,
+ /// never zero, never repeating inside one run.
+ ///
+ private static readonly (float X, float Y)[] JitterPhases =
+ {
+ (0.25f, -0.375f), (-0.125f, 0.25f), (0.375f, 0.125f),
+ (-0.375f, -0.25f), (0.125f, 0.375f), (-0.25f, -0.125f),
+ };
+
+ /// The Vulkan platform without a window: the size seam answers for one.
+ private sealed class ChainPlatform : VulkanClientPlatform
+ {
+ public ChainPlatform() : base(null!)
+ {
+ }
+
+ ///
+ /// The window the size seam answers with. Render scale below 1 is this size divided by
+ /// the render targets' size, exactly as the client computes it: client 32 at ssaa 0.5
+ /// gives the same 16-pixel targets as client 16 at ssaa 1.
+ ///
+ public Size2i ClientSize { get; set; } = new(NativePostChainTests.Size, NativePostChainTests.Size);
+
+ ///
+ /// The god-rays pass takes its time uniform from here. Pinned so the two routes of a
+ /// differential run cannot be handed different values by the wall clock.
+ ///
+ public override long EllapsedMs => 4242;
+
+ public override Size2i OptimumWindowClientSize() => ClientSize;
+
+ ///
+ /// No window is opened here, and the base's cases size their viewports from
+ /// NativeWindow.ClientSize, which is GLFW-backed. Every post target in this fixture is
+ /// built at exactly the size its case computes - Primary and Luma at the render
+ /// resolution, the bloom pair at half and at a quarter of it, god rays at half - so
+ /// binding the target and taking the viewport from it is the same bind and the same
+ /// viewport, for the OpenGL route and the native one alike.
+ ///
+ public override void LoadFrameBuffer(EnumFrameBuffer framebuffer)
+ {
+ if (framebuffer == EnumFrameBuffer.Primary)
+ {
+ CurrentFrameBuffer = FrameBuffers[0];
+ return;
+ }
+ switch (framebuffer)
+ {
+ case EnumFrameBuffer.BlurHorizontalMedRes:
+ case EnumFrameBuffer.BlurVerticalMedRes:
+ case EnumFrameBuffer.BlurHorizontalLowRes:
+ case EnumFrameBuffer.BlurVerticalLowRes:
+ case EnumFrameBuffer.GodRays:
+ CurrentFrameBuffer = FrameBuffers[(int)framebuffer];
+ return;
+ }
+ base.LoadFrameBuffer(framebuffer);
+ }
+ }
+
+ // ------------------------------------------------------------------ the tests
+
+ ///
+ /// The OIT merge with the motion window open: the shaded image, the glow attachment and the
+ /// motion attachment all have to come out of the native pass exactly as the OpenGL body
+ /// leaves them, including the additive (ONE, ONE) blend that only touches the reactive
+ /// channel.
+ ///
+ [SkippableFact]
+ public void TheOitMergeMatchesTheOpenGlBodyWithTheMotionWindowOpen()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+
+ Frame stated = RunMerge(session, native: false);
+
+ long passesBefore = session.Seam.NativePassesForTests;
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ Frame nativeRoute = RunMerge(session, native: true);
+
+ Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore);
+
+ Assert.Equal(stated.Scene, nativeRoute.Scene);
+ Assert.Equal(stated.Glow, nativeRoute.Glow);
+ Assert.Equal(stated.Motion, nativeRoute.Motion);
+
+ // The merge really did add into the reactive channel, or the comparison above would
+ // pass on two routes that both wrote nothing.
+ Assert.True(Reactive(nativeRoute.Motion, Size / 2, Size / 2) > Reactive(session.MotionSeed, 0, 0),
+ "the merge did not accumulate coverage into the reactive channel");
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The same merge with TAA off: the motion window never opens, so the pass writes the two
+ /// world attachments and leaves the motion attachment exactly as it found it.
+ ///
+ [SkippableFact]
+ public void TheOitMergeMatchesTheOpenGlBodyWithoutTheMotionWindow()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: false);
+
+ Frame stated = RunMerge(session, native: false);
+ Frame nativeRoute = RunMerge(session, native: true);
+
+ Assert.Equal(stated.Scene, nativeRoute.Scene);
+ Assert.Equal(stated.Glow, nativeRoute.Glow);
+ Assert.Equal(stated.Motion, nativeRoute.Motion);
+ Assert.Equal(session.MotionSeed, nativeRoute.Motion);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// Sky motion: the motion attachment the native pass writes is the OpenGL body's, and the
+ /// shaded image is untouched - the pass writes one colour slot and no depth.
+ ///
+ [SkippableFact]
+ public void SkyMotionMatchesTheOpenGlBody()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+
+ Frame stated = RunSkyMotion(session, native: false);
+
+ long passesBefore = session.Seam.NativePassesForTests;
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ Frame nativeRoute = RunSkyMotion(session, native: true);
+
+ Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore);
+
+ Assert.Equal(stated.Motion, nativeRoute.Motion);
+ Assert.Equal(stated.Scene, nativeRoute.Scene);
+ Assert.Equal(session.SceneSeed, nativeRoute.Scene);
+
+ // The pass covered the sky, or "the two routes agree" would be vacuous.
+ Assert.True(Reactive(nativeRoute.Motion, Size / 2, Size / 2) > 0.5f,
+ "sky motion wrote no reactive value, so the depth test rejected the whole target");
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ /// The OpenGL body on this device is the generic stated route; the native chain is not.
+ [SkippableFact]
+ public void TheOpenGlRouteDrawsThroughTheStatedRouteAndTheNativeChainDoesNot()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+
+ long nativeDrawsBefore = session.Seam.NativeDrawsForTests;
+ long statedBefore = session.Platform.StatedDrawsForTests;
+ RunMerge(session, native: false);
+ Assert.Equal(0, session.Seam.NativeDrawsForTests - nativeDrawsBefore);
+ Assert.True(session.Platform.StatedDrawsForTests - statedBefore > 0);
+
+ RunMerge(session, native: true);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// Several frames through the native chain: every frame runs the steps in the declared
+ /// order, and TAA keeps accumulating - the resolve runs each frame, the history parity
+ /// alternates so each frame reads the slot the last one wrote, and the motion attachment it
+ /// reads carries what the merge and sky motion wrote before it.
+ ///
+ /// The final composition is the one declared step not driven here: its body reads
+ /// NativeWindow.ClientSize directly, which a windowless test cannot answer. Its position in
+ /// the chain is pinned by the source coverage test (Optimum.Tests, native-post-chain).
+ ///
+ [SkippableFact]
+ public void TheChainKeepsItsOrderAcrossFramesAndTaaKeepsAccumulating()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+
+ VulkanDevice seam = session.Seam;
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = true;
+
+ var expected = new List();
+ foreach (VulkanClientPlatform.NativePostStep step in VulkanClientPlatform.NativePostChainOrder)
+ {
+ if (step != VulkanClientPlatform.NativePostStep.FinalComposition) expected.Add(step);
+ }
+
+ var resolvedTextures = new List();
+ const int frames = 4;
+ for (int frame = 0; frame < frames; frame++)
+ {
+ session.AdvanceTemporalFrame();
+ var log = new List();
+ platform.NativePostStepLog = log;
+
+ platform.BeginFrame();
+ session.SeedFrame();
+
+ platform.CurrentFrameBuffer = session.Primary;
+ platform.MergeTransparentRenderPass();
+ platform.CurrentFrameBuffer = session.Primary;
+ platform.RenderOptimumSkyMotion();
+ platform.RenderPostprocessingEffects(null);
+ platform.BlitPrimaryToDefault();
+
+ platform.NativePostStepLog = null;
+ Assert.Equal(expected, log);
+ Assert.True(platform.TaaResolvedThisFrame, "the resolve did not run on frame " + frame);
+ resolvedTextures.Add(ResolvedColorTexture(platform));
+
+ byte[] motion = session.ReadMotion();
+ Assert.True(Reactive(motion, Size / 2, Size / 2) > 0.5f,
+ "frame " + frame + " reached the resolve with an empty motion attachment, " +
+ "so the merge and sky motion did not run before it");
+
+ platform.EndFrame();
+ }
+
+ // The resolve alternates its history slots, so every frame reads the one the previous
+ // frame wrote: that alternation is the accumulation.
+ int slotA = session.History(0).ColorTextureIds[0];
+ int slotB = session.History(1).ColorTextureIds[0];
+ for (int frame = 0; frame < frames; frame++)
+ {
+ Assert.Equal(frame % 2 == 0 ? slotA : slotB, resolvedTextures[frame]);
+ }
+ Assert.True(HistoryValid(platform), "the resolve left the history invalid");
+
+ GpuTest.AssertClean(seam);
+ }
+
+ ///
+ /// The TAA resolve, natively: all three attachments of the history slot it writes - the
+ /// resolved colour, the resolved glow and the linear depth - have to be the OpenGL body's,
+ /// from a cold history (the reset frame, which copies the scene through) and from a warm one
+ /// (the frame that actually blends history in).
+ ///
+ /// The temporal invariants are the shader's, and both routes run the same taa-resolve.fsh: what is
+ /// asserted here is that the native route feeds it the same seven textures and the same nine
+ /// uniform values, so the 3x3 nearest-depth disocclusion and the luminance anti-flicker
+ /// weighting see identical inputs and produce identical pixels.
+ ///
+ [SkippableTheory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void TheTaaResolveMatchesTheOpenGlBody(bool warmHistory)
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+ session.PatternedScene = true;
+
+ Resolved stated = RunResolve(session, native: false, warmHistory);
+
+ long passesBefore = session.Seam.NativePassesForTests;
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ Resolved nativeRoute = RunResolve(session, native: true, warmHistory);
+
+ Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore);
+
+ Assert.Equal(stated.Color, nativeRoute.Color);
+ Assert.Equal(stated.Glow, nativeRoute.Glow);
+ Assert.Equal(stated.Depth, nativeRoute.Depth);
+
+ // The pass wrote a resolved image over the seed, or the comparison above would hold
+ // for two routes that both wrote nothing.
+ Assert.NotEqual(session.HistorySeedColor, nativeRoute.Color);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The TAA resolve with TAA off: the lib body returns before any draw, so the native route
+ /// declares no pass at all and the history is marked invalid on both routes.
+ ///
+ [SkippableFact]
+ public void TheTaaResolveDrawsNothingWithTaaOff()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+ OptimumConfig.Taa = false;
+
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = true;
+ long passesBefore = session.Seam.NativePassesForTests;
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ platform.CurrentFrameBuffer = session.Primary;
+ Assert.False(platform.RenderOptimumTaaResolve());
+ platform.EndFrame();
+
+ Assert.Equal(0, session.Seam.NativePassesForTests - passesBefore);
+ Assert.False(platform.TaaResolvedThisFrame);
+ Assert.False(HistoryValid(platform));
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The TAA sharpen, natively: the sharpen target's single attachment has to be the OpenGL
+ /// body's at every strength the setting can take, and the texture the pass hands on to the
+ /// rest of the chain has to be the sharpen target either way.
+ ///
+ [SkippableTheory]
+ [InlineData(1f)]
+ [InlineData(0.35f)]
+ public void TheTaaSharpenMatchesTheOpenGlBody(float sharpness)
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+ session.PatternedScene = true;
+ OptimumConfig.TaaSharpness = sharpness;
+
+ byte[] stated = RunSharpen(session, native: false, out int statedTexture);
+
+ long passesBefore = session.Seam.NativePassesForTests;
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+ byte[] nativeRoute = RunSharpen(session, native: true, out int nativeTexture);
+
+ // One native pass for the resolve that has to run first, one for the sharpen.
+ Assert.Equal(2, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(2, session.Seam.NativeDrawsForTests - drawsBefore);
+
+ Assert.Equal(session.Sharpen.ColorTextureIds[0], statedTexture);
+ Assert.Equal(session.Sharpen.ColorTextureIds[0], nativeTexture);
+ Assert.Equal(stated, nativeRoute);
+ Assert.NotEqual(session.SharpenSeed, nativeRoute);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The sharpen's conditions live in the lib body, so both routes skip it on exactly the same
+ /// frames: sharpness at zero hands the resolved texture straight on and draws nothing.
+ ///
+ [SkippableFact]
+ public void TheTaaSharpenIsSkippedOnBothRoutesWhenThereIsNothingToSharpen()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+ OptimumConfig.TaaSharpness = 0f;
+
+ ChainPlatform platform = session.Platform;
+ foreach (bool native in new[] { false, true })
+ {
+ platform.NativePostChainEnabled = native;
+ long passesBefore = session.Seam.NativePassesForTests;
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ platform.CurrentFrameBuffer = session.Primary;
+ Assert.True(platform.RenderOptimumTaaResolve());
+ int resolved = platform.OptimumPostSceneTexture();
+ Assert.Equal(resolved, platform.RenderOptimumTaaSharpen(resolved));
+ platform.EndFrame();
+
+ // The resolve's pass on the native route, and nothing for the sharpen.
+ Assert.Equal(native ? 1 : 0, session.Seam.NativePassesForTests - passesBefore);
+ }
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// Several frames of the native resolve with nothing read back between them: the history is
+ /// still being accumulated, not replaced. Five frames do not land where one frame lands -
+ /// each frame reprojects the previous slot at its own sub-pixel jitter and blends it in -
+ /// and where they land is the OpenGL body's answer to the identical sequence.
+ ///
+ /// A single-frame readback cannot see this: it passed while the R32F-history and
+ /// masked-clear bugs were live (P2, 2026-09-10), which is why the loop below reads nothing.
+ ///
+ [SkippableFact]
+ public void TheNativeResolveKeepsAccumulatingHistoryAcrossFrames()
+ {
+ using Session session = Open();
+ session.EnableTaa(jitterActive: true);
+ session.PatternedScene = true;
+
+ // The same last frame - the same jitter phase, the same scene, the same slot - reached
+ // two ways: cold, and after four frames of history. Pinning the phase is what makes the
+ // difference between them history and nothing else.
+ byte[] lastFrameAlone = RunResolveFrames(session, native: true, frames: 1, startPhase: 4);
+ byte[] fiveFrames = RunResolveFrames(session, native: true, frames: 5, startPhase: 0);
+ Assert.NotEqual(lastFrameAlone, fiveFrames);
+
+ byte[] statedFive = RunResolveFrames(session, native: false, frames: 5, startPhase: 0);
+ Assert.Equal(statedFive, fiveFrames);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ // ------------------------------------------------- the chain's tail, both routes
+
+ ///
+ /// The settings that change the chain's tail. Each row is one run of the whole chain on both
+ /// routes: bloom, god rays, FXAA, vanilla SSAO, TAA, the render scale (client size over target
+ /// size), the AO debug view and which AO texture the frame produced.
+ ///
+ public static TheoryData TailSettings()
+ {
+ var data = new TheoryData();
+ // name bloom rays fxaa ssao taa ssaa client debug gtao
+ data.Add("everything-off", false, false, false, false, false, 1f, Size, false, false);
+ data.Add("bloom", true, false, false, false, false, 1f, Size, false, false);
+ data.Add("god-rays", false, true, false, false, false, 1f, Size, false, false);
+ data.Add("fxaa", false, false, true, false, false, 1f, Size, false, false);
+ data.Add("bloom-rays-fxaa", true, true, true, false, false, 1f, Size, false, false);
+ data.Add("ssao", true, true, false, true, false, 1f, Size, false, false);
+ data.Add("ssao-debug-view", false, false, false, true, false, 1f, Size, true, false);
+ data.Add("ssao-gtao", false, false, false, true, false, 1f, Size, false, true);
+ data.Add("ssao-gtao-debug", false, false, false, true, false, 1f, Size, true, true);
+ data.Add("taa", true, true, true, false, true, 1f, Size, false, false);
+ data.Add("taa-ssao", true, true, true, true, true, 1f, Size, false, false);
+ data.Add("render-scale-half", true, true, true, true, false, 0.5f, Size * 2, false, false);
+ return data;
+ }
+
+ ///
+ /// Behavioural identity (decision 6) for the four passes this stage made native: the bloom
+ /// chain, god rays, the Luma step and the final composition. The whole chain runs twice from
+ /// identically seeded targets - once on the OpenGL body, once natively - and every target the
+ /// tail writes has to come out the same, bitwise: the find-bright image, the low-resolution
+ /// bloom result the composition reads, the god-ray target, the Luma target and Primary colour
+ /// 0. The motion attachment is checked too, because the composition keeps it in its scope
+ /// while writing colour 0 and must not touch it.
+ ///
+ [SkippableTheory]
+ [MemberData(nameof(TailSettings))]
+ public void TheChainTailMatchesTheOpenGlBodyAcrossThePostSettings(string name, bool bloom, bool godRays,
+ bool fxaa, bool ssao, bool taa, float ssaa, int clientSize, bool debugView, bool gtao)
+ {
+ using Session session = Open();
+ if (taa) session.EnableTaa(jitterActive: true);
+ else OptimumConfig.Taa = false;
+ OptimumConfig.AmbientOcclusionDebugView = debugView;
+ session.ApplyPostSettings(bloom, godRays, fxaa, ssao, ssaa, clientSize);
+
+ int aoTexture = gtao ? session.SsaoBlurTexture : 0;
+ TailFrame stated = RunTail(session, native: false, aoInScene: gtao, aoTexture: aoTexture);
+
+ long passesBefore = session.Seam.NativePassesForTests;
+ long copiesBefore = session.Seam.ReadSelfCopiesForTests.Created;
+ TailFrame nativeRoute = RunTail(session, native: true, aoInScene: gtao, aoTexture: aoTexture);
+
+ // The Luma step and the final composition always draw; bloom adds five passes, god rays
+ // one, and TAA one more for the resolve (the sharpen declares no pass of its own). No
+ // native pass reached the generic stated route, and the composition's self-read took no
+ // feedback copy - the declared attachment subset is what makes it safe.
+ long expectedPasses = 2 + (bloom ? 5 : 0) + (godRays ? 1 : 0) + (taa ? 1 : 0);
+ Assert.Equal(expectedPasses, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created);
+
+ Assert.Equal(stated.FindBright, nativeRoute.FindBright);
+ Assert.Equal(stated.BloomLow, nativeRoute.BloomLow);
+ Assert.Equal(stated.GodRays, nativeRoute.GodRays);
+ Assert.Equal(stated.Luma, nativeRoute.Luma);
+ Assert.Equal(stated.Final, nativeRoute.Final);
+ Assert.Equal(stated.Motion, nativeRoute.Motion);
+
+ // The composition really wrote something, or "the two routes agree" would be vacuous.
+ Assert.NotEqual(session.SceneSeed, nativeRoute.Final);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ ///
+ /// The final composition samples Primary colour 1 as the glow on a frame with no TAA resolve,
+ /// while it writes Primary colour 0. The pass declares the attachment subset, so colour 1 is
+ /// moved to the shader-read layout for the pass and back afterwards - the frame takes no
+ /// feedback copy - and the glow attachment itself comes out of the pass unchanged.
+ ///
+ [SkippableFact]
+ public void TheFinalCompositionReadsPrimaryColourOneWithoutAFeedbackCopy()
+ {
+ using Session session = Open();
+ OptimumConfig.Taa = false;
+ OptimumConfig.AmbientOcclusionDebugView = false;
+ session.ApplyPostSettings(bloom: false, godRays: false, fxaa: false, ssao: false, ssaa: 1f,
+ clientSize: Size);
+
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = true;
+ session.ResetTaaHistory();
+
+ long copiesBefore = session.Seam.ReadSelfCopiesForTests.Created;
+ long passesBefore = session.Seam.NativePassesForTests;
+ long drawsBefore = session.Seam.NativeDrawsForTests;
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ platform.CurrentFrameBuffer = session.Primary;
+ Assert.False(platform.TaaResolvedThisFrame);
+
+ platform.RenderFinalComposition();
+
+ byte[] glow = session.ReadGlow();
+ byte[] scene = session.ReadScene();
+ platform.EndFrame();
+
+ Assert.Equal(1, session.Seam.NativePassesForTests - passesBefore);
+ Assert.Equal(1, session.Seam.NativeDrawsForTests - drawsBefore);
+ Assert.Equal(copiesBefore, session.Seam.ReadSelfCopiesForTests.Created);
+ Assert.NotEqual(session.SceneSeed, scene);
+ Assert.Equal(session.GlowSeed, glow);
+
+ GpuTest.AssertClean(session.Seam);
+ }
+
+ // ---------------------------------------------------------------------- driving
+
+ private readonly record struct Frame(byte[] Scene, byte[] Glow, byte[] Motion);
+
+ /// The three attachments of the history slot a resolve wrote.
+ private readonly record struct Resolved(byte[] Color, byte[] Glow, byte[] Depth);
+
+ ///
+ /// One TAA resolve on the route under test, from an identically seeded frame: the history
+ /// parity pinned to slot A, both history slots seeded, and the jitter pinned to phase 0 so
+ /// the two routes see the same sub-pixel offset.
+ ///
+ private Resolved RunResolve(Session session, bool native, bool warmHistory)
+ {
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = native;
+ session.AdvanceTemporalFrame(0);
+ SetParity(platform, 0);
+ SetHistoryValid(platform, warmHistory);
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ session.SeedHistory();
+ platform.CurrentFrameBuffer = session.Primary;
+
+ Assert.True(platform.RenderOptimumTaaResolve(), "the resolve did not run");
+ Assert.Equal(1, Parity(platform));
+
+ var resolved = new Resolved(session.ReadHistoryColor(0), session.ReadHistoryGlow(0),
+ session.ReadHistoryDepth(0));
+ platform.EndFrame();
+ return resolved;
+ }
+
+ /// One resolve and one sharpen on the route under test, from an identically seeded frame.
+ private byte[] RunSharpen(Session session, bool native, out int handedOn)
+ {
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = native;
+ session.AdvanceTemporalFrame(0);
+ SetParity(platform, 0);
+ SetHistoryValid(platform, true);
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ session.SeedHistory();
+ session.SeedSharpen();
+ platform.CurrentFrameBuffer = session.Primary;
+
+ Assert.True(platform.RenderOptimumTaaResolve(), "the resolve did not run");
+ handedOn = platform.RenderOptimumTaaSharpen(platform.OptimumPostSceneTexture());
+
+ byte[] sharpened = session.ReadSharpen();
+ platform.EndFrame();
+ return sharpened;
+ }
+
+ ///
+ /// resolves on the route under test, with no readback inside the
+ /// loop - only the frame boundary between them - and the history read once at the end. The
+ /// run starts cold, so the first frame is the reset frame and every frame after it blends.
+ /// An odd frame count always ends on slot A, so two runs of different length are comparable.
+ ///
+ private byte[] RunResolveFrames(Session session, bool native, int frames, int startPhase)
+ {
+ Assert.True(frames % 2 == 1, "an even frame count would end on the other history slot");
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = native;
+ SetParity(platform, 0);
+ SetHistoryValid(platform, false);
+
+ platform.BeginFrame();
+ session.SeedHistory();
+ platform.EndFrame();
+
+ for (int frame = 0; frame < frames; frame++)
+ {
+ session.AdvanceTemporalFrame(startPhase + frame);
+ platform.BeginFrame();
+ session.SeedFrame();
+ platform.CurrentFrameBuffer = session.Primary;
+ Assert.True(platform.RenderOptimumTaaResolve(), "the resolve did not run on frame " + frame);
+ platform.EndFrame();
+ }
+
+ platform.BeginFrame();
+ byte[] history = session.ReadHistoryColor(0);
+ platform.EndFrame();
+ return history;
+ }
+
+ /// Everything the chain's tail writes, on one route.
+ private readonly record struct TailFrame(byte[] FindBright, byte[] BloomLow, byte[] GodRays,
+ byte[] Luma, byte[] Final, byte[] Motion);
+
+ ///
+ /// One whole post chain plus the final composition, on the route under test, from identically
+ /// seeded targets. The two AO fields are set between the two calls because the chain's first
+ /// step - the AO step, which is still the OpenGL body on both routes - resets them, exactly as
+ /// a real frame's GTAO pass would then fill them in.
+ ///
+ private TailFrame RunTail(Session session, bool native, bool aoInScene, int aoTexture)
+ {
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = native;
+ session.ResetTaaHistory();
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ // The history the resolve starts from, so both routes run the chain from identical
+ // contents; SeedFrame leaves the history alone so frames can accumulate across it.
+ session.SeedHistory();
+ platform.CurrentFrameBuffer = session.Primary;
+
+ platform.RenderPostprocessingEffects(null);
+ session.ApplyAmbientOcclusionState(aoInScene, aoTexture);
+ platform.RenderFinalComposition();
+
+ var frame = new TailFrame(
+ session.ReadPostTarget(4),
+ session.ReadPostTarget(8),
+ session.ReadPostTarget(7),
+ session.ReadPostTarget(10),
+ session.ReadScene(),
+ session.ReadMotion());
+ platform.EndFrame();
+ return frame;
+ }
+
+ /// One OIT merge, on the route under test, from an identically seeded frame.
+ private Frame RunMerge(Session session, bool native)
+ {
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = native;
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ platform.CurrentFrameBuffer = session.Primary;
+
+ platform.MergeTransparentRenderPass();
+
+ var frame = new Frame(session.ReadScene(), session.ReadGlow(), session.ReadMotion());
+ platform.EndFrame();
+ return frame;
+ }
+
+ /// One sky-motion pass, on the route under test, from an identically seeded frame.
+ private Frame RunSkyMotion(Session session, bool native)
+ {
+ ChainPlatform platform = session.Platform;
+ platform.NativePostChainEnabled = native;
+ session.AdvanceTemporalFrame();
+
+ platform.BeginFrame();
+ session.SeedFrame();
+ platform.CurrentFrameBuffer = session.Primary;
+
+ bool drawn = platform.RenderOptimumSkyMotion();
+ Assert.True(drawn, "the sky motion pass did not run");
+
+ var frame = new Frame(session.ReadScene(), session.ReadGlow(), session.ReadMotion());
+ platform.EndFrame();
+ return frame;
+ }
+
+ /// The reactive channel of a decoded motion pixel (motion.b, in [0, 1]).
+ private static float Reactive(byte[] decoded, int x, int y) => decoded[(y * Size + x) * 4 + 2] / 255f;
+
+ private static int ResolvedColorTexture(ClientPlatformWindows platform) =>
+ (int)typeof(ClientPlatformWindows)
+ .GetField("taaResolvedColorTexture", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .GetValue(platform)!;
+
+ private static int Parity(ClientPlatformWindows platform) =>
+ (int)ParityField.GetValue(platform)!;
+
+ private static void SetParity(ClientPlatformWindows platform, int parity) =>
+ ParityField.SetValue(platform, parity);
+
+ private static void SetHistoryValid(ClientPlatformWindows platform, bool valid) =>
+ HistoryValidField.SetValue(platform, valid);
+
+ private static readonly FieldInfo ParityField = typeof(ClientPlatformWindows)
+ .GetField("_taaFrameParity", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ private static readonly FieldInfo HistoryValidField = typeof(ClientPlatformWindows)
+ .GetField("_taaHistoryValid", BindingFlags.Instance | BindingFlags.NonPublic)!;
+
+ private static bool HistoryValid(ClientPlatformWindows platform) =>
+ (bool)typeof(ClientPlatformWindows)
+ .GetField("_taaHistoryValid", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .GetValue(platform)!;
+
+ // ---------------------------------------------------------------------- session
+
+ private Session Open()
+ {
+ (string manifest, string reason) = NativeManifest.Value;
+ Skip.If(manifest.Length == 0, reason);
+ Skip.If(ShaderCorpus.AssetRoot == null, "No bootstrapped game assets.");
+
+ Session? session = Session.TryOpen(output, manifest);
+ Skip.If(session == null, "No usable Vulkan device.");
+ return session!;
+ }
+
+ ///
+ /// The platform, its device, the targets the chain indexes, the programs its passes use and
+ /// the client statics they read, all put back afterwards.
+ ///
+ private sealed class Session : IDisposable
+ {
+ public ChainPlatform Platform { get; private init; } = null!;
+ public VulkanDevice Seam => Platform.GraphicsDevice!;
+ public FrameBufferRef Primary { get; private set; } = null!;
+ public FrameBufferRef Transparent { get; private set; } = null!;
+ public FrameBufferRef Sharpen { get; private set; } = null!;
+
+ ///
+ /// True leaves Primary's colour 0 as the pattern it was created with instead of clearing
+ /// it flat: the TAA resolve's variance clip box collapses on a flat image, so a flat
+ /// scene would make every frame after the first return the current frame untouched and
+ /// hide whether history is being blended in at all.
+ ///
+ public bool PatternedScene { get; set; }
+
+ /// The history slots' seed and the sharpen target's, as the readbacks decode them.
+ public byte[] HistorySeedColor { get; private set; } = Array.Empty();
+ public byte[] SharpenSeed { get; private set; } = Array.Empty();
+
+ /// The motion attachment's seed, decoded the way decodes it.
+ public byte[] MotionSeed { get; private set; } = Array.Empty();
+
+ /// The shaded image's seed, as read back.
+ public byte[] SceneSeed { get; private set; } = Array.Empty();
+
+ /// The glow attachment's seed, decoded the way decodes it.
+ public byte[] GlowSeed { get; private set; } = Array.Empty();
+
+ private readonly List buffers = new();
+ private int oitReveal;
+ private int oitAccumulation;
+ private int scenePattern;
+ private int decodeProgram;
+ private int decodeTarget;
+ private int decodeFramebuffer;
+ private readonly Dictionary<(int Width, int Height), int> decodeFramebuffers = new();
+ private ClientPlatformAbstract? previousPlatform;
+ private string dataPath = "";
+ private ShaderProgramTransparentcompose? composeBefore;
+ private ShaderProgram? skyMotionBefore;
+ private ShaderProgram? resolveBefore;
+ private ShaderProgram? sharpenBefore;
+ private ShaderProgramBlit? blitBefore;
+ private ShaderProgramFindbright? findbrightBefore;
+ private ShaderProgramBlur? blurBefore;
+ private ShaderProgramGodrays? godraysBefore;
+ private ShaderProgramLuma? lumaBefore;
+ private ShaderProgramFinal? finalBefore;
+ private bool taaBefore;
+ private float sharpnessBefore;
+ private bool debugViewBefore;
+ private object? oitRevealBefore;
+ private object? oitAccumBefore;
+ private DefaultShaderUniforms uniforms = new();
+
+ private const BindingFlags Hidden = BindingFlags.Instance | BindingFlags.NonPublic;
+ private const BindingFlags HiddenStatic = BindingFlags.Static | BindingFlags.NonPublic;
+
+ public FrameBufferRef History(int parity) => buffers[parity == 0 ? 19 : 20];
+
+ public static Session? TryOpen(ITestOutputHelper output, string manifestDirectory)
+ {
+ string dataPath = Path.Combine(Path.GetTempPath(), "optimum-native-post-" + Guid.NewGuid().ToString("N"));
+ var platform = new ChainPlatform
+ {
+ DeviceFactory = () =>
+ {
+ VulkanDevice created = GpuTest.NewDevice();
+ created.NativeShaderDirectory = manifestDirectory;
+ created.NativeShadersEnabled = true;
+ created.IgnoreModShaderScan = true;
+ return created;
+ },
+ CrashMarkerDataPath = dataPath,
+ };
+
+ if (!platform.InitializeGraphics(IntPtr.Zero, Size, Size, out string reason))
+ {
+ output.WriteLine("Vulkan unavailable: " + reason);
+ platform.ShutdownGraphics();
+ return null;
+ }
+
+ var session = new Session
+ {
+ Platform = platform,
+ previousPlatform = ScreenManager.Platform,
+ dataPath = dataPath,
+ composeBefore = ShaderPrograms.Transparentcompose,
+ skyMotionBefore = ShaderPrograms.TaaSkyMotion,
+ resolveBefore = ShaderPrograms.TaaResolve,
+ sharpenBefore = ShaderPrograms.TaaSharpen,
+ blitBefore = ShaderPrograms.Blit,
+ findbrightBefore = ShaderPrograms.Findbright,
+ blurBefore = ShaderPrograms.Blur,
+ godraysBefore = ShaderPrograms.Godrays,
+ lumaBefore = ShaderPrograms.Luma,
+ finalBefore = ShaderPrograms.Final,
+ taaBefore = OptimumConfig.Taa,
+ sharpnessBefore = OptimumConfig.TaaSharpness,
+ debugViewBefore = OptimumConfig.AmbientOcclusionDebugView,
+ };
+ ScreenManager.Platform = platform;
+ ScreenManager.FrameProfiler ??= new FrameProfilerUtil(static (string _) => { });
+ platform.ShaderUniforms = session.uniforms;
+
+ session.BuildTargets();
+ session.LinkPrograms();
+ session.InstallState();
+ return session;
+ }
+
+ public void Dispose()
+ {
+ ShaderProgramBase.CurrentShaderProgram = null;
+ ShaderPrograms.Transparentcompose = composeBefore!;
+ ShaderPrograms.TaaSkyMotion = skyMotionBefore!;
+ ShaderPrograms.TaaResolve = resolveBefore!;
+ ShaderPrograms.TaaSharpen = sharpenBefore!;
+ ShaderPrograms.Blit = blitBefore!;
+ ShaderPrograms.Findbright = findbrightBefore!;
+ ShaderPrograms.Blur = blurBefore!;
+ ShaderPrograms.Godrays = godraysBefore!;
+ ShaderPrograms.Luma = lumaBefore!;
+ ShaderPrograms.Final = finalBefore!;
+ OptimumConfig.Taa = taaBefore;
+ OptimumConfig.TaaSharpness = sharpnessBefore;
+ OptimumConfig.AmbientOcclusionDebugView = debugViewBefore;
+ OptimumTemporal.Frame.JitterActive = false;
+ typeof(SystemRenderOITLayers).GetField("revealTextureId", HiddenStatic)!.SetValue(null, oitRevealBefore);
+ typeof(SystemRenderOITLayers).GetField("accumTextureId", HiddenStatic)!.SetValue(null, oitAccumBefore);
+ ScreenManager.Platform = previousPlatform!;
+ Platform.ShutdownGraphics();
+ try
+ {
+ Directory.Delete(dataPath, true);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ }
+ }
+
+ // ------------------------------------------------------------ per-frame state
+
+ ///
+ /// The state the AfterOIT stage leaves for the merge, and the inputs both routes read:
+ /// Primary cleared to the seeds, the Transparent target's OIT attachments cleared, the
+ /// world draw-buffer mask, and the OIT textures on the units the OIT renderer uses.
+ ///
+ public void SeedFrame()
+ {
+ VulkanDevice seam = Seam;
+
+ seam.BindFramebuffer(Transparent.FboId);
+ seam.SetDrawBuffers(Transparent.FboId, 0b111001);
+ seam.ClearColor(0, 0.6f, 0.45f, 0.3f, 1f);
+ seam.ClearColor(3, 0.30f, 0.10f, 0.05f, 0.5f);
+ seam.ClearColor(4, 0.10f, 0.25f, 0.05f, 0.35f);
+ seam.ClearColor(5, 0.05f, 0.10f, 0.30f, 0.2f);
+
+ SeedPostTargets();
+
+ seam.BindFramebuffer(Primary.FboId);
+ seam.SetDrawBuffers(Primary.FboId, 0b111);
+ if (!PatternedScene) seam.ClearColor(0, 0.25f, 0.5f, 0.75f, 1f);
+ seam.ClearColor(1, 0.125f, 0.25f, 0.375f, 1f);
+ seam.ClearColor(2, 0f, 0f, 0.125f, 0.5f);
+ seam.ClearDepth(1f);
+ if (PatternedScene) DrawScenePattern();
+ // Primary's default colour set: two attachments without the SSAO G-buffer.
+ seam.SetDrawBuffers(Primary.FboId, 0b011);
+
+ seam.SetViewport(0, 0, Size, Size);
+ seam.SetBlend(true, EnumBlendMode.Standard);
+ seam.SetDepthTest(false);
+ seam.SetDepthMask(true);
+ seam.SetCullFace(false);
+
+ // The units SystemRenderOITLayers points the merge's two OIT samplers at.
+ ShaderProgramTransparentcompose compose = ShaderPrograms.Transparentcompose;
+ seam.SetSamplerUnit(compose.ProgramId, "OITreveal", 6);
+ seam.SetSamplerUnit(compose.ProgramId, "OITaccumulation", 7);
+ seam.BindTexture(6, oitReveal);
+ seam.BindTexture(7, oitAccumulation);
+ }
+
+ /// The post chain's targets, indexed by their EnumFrameBuffer slot.
+ private static readonly int[] PostTargetIndices = { 2, 3, 4, 7, 8, 9, 10, 14 };
+
+ ///
+ /// Every post target seeded to its own constant: the final composition samples the bloom
+ /// and god-ray targets whether or not their passes ran this frame, so both routes have to
+ /// start a run from identical contents. The TAA history is NOT seeded here - a frame of
+ /// the chain must be able to run after another one and find the history the previous
+ /// frame wrote, which is what the accumulation test measures. Seed it with
+ /// where a run needs a known starting history.
+ ///
+ private void SeedPostTargets()
+ {
+ VulkanDevice seam = Seam;
+ for (int i = 0; i < PostTargetIndices.Length; i++)
+ {
+ FrameBufferRef target = buffers[PostTargetIndices[i]];
+ if (target == null) continue;
+ float level = 0.08f + i * 0.09f;
+ seam.BindFramebuffer(target.FboId);
+ seam.SetDrawBuffers(target.FboId, 0b1);
+ seam.ClearColor(0, level, 1f - level, level * 0.5f, 1f);
+ }
+ }
+
+ ///
+ /// The TAA resolve's history slot selection put back to its starting state, so two
+ /// differential runs of the same chain resolve into the same slot from the same history.
+ ///
+ public void ResetTaaHistory()
+ {
+ typeof(ClientPlatformWindows).GetField("_taaFrameParity", Hidden)!.SetValue(Platform, 0);
+ typeof(ClientPlatformWindows).GetField("_taaHistoryValid", Hidden)!.SetValue(Platform, false);
+ }
+
+ ///
+ /// The per-frame post switches window_RenderFrame computes, and the render scale: private
+ /// fields of ClientPlatformWindows, which is where the chain reads them from on both
+ /// routes (through OptimumRenderBloom and friends on the native one).
+ ///
+ public void ApplyPostSettings(bool bloom, bool godRays, bool fxaa, bool ssao, float ssaa, int clientSize)
+ {
+ SetField("RenderBloom", bloom);
+ SetField("RenderGodRays", godRays);
+ SetField("RenderFXAA", fxaa);
+ SetField("RenderSSAO", ssao);
+ SetField("ssaaLevel", ssaa);
+ Platform.ClientSize = new Size2i(clientSize, clientSize);
+ }
+
+ /// The two AO fields the final composition reads, as the AO step would have left them.
+ public void ApplyAmbientOcclusionState(bool inScene, int platformTexture)
+ {
+ SetField("optimumSsaoInScene", inScene);
+ SetField("optimumAmbientOcclusionTexture", platformTexture);
+ }
+
+ /// The blurred vanilla-SSAO target the final composition binds when AO is on.
+ public int SsaoBlurTexture => buffers[14].ColorTextureIds[0];
+
+ private void SetField(string name, object value) =>
+ typeof(ClientPlatformWindows).GetField(name, Hidden)!.SetValue(Platform, value);
+
+ ///
+ /// The scene pattern into Primary's colour 0, the same every frame: a clear can only
+ /// write a flat image, and a flat image collapses the resolve's variance clip box.
+ ///
+ private void DrawScenePattern()
+ {
+ VulkanDevice seam = Seam;
+ seam.SetDrawBuffers(Primary.FboId, 0b001);
+ seam.UseProgram(decodeProgram);
+ seam.SetSamplerUnit(decodeProgram, "source", 15);
+ seam.BindTexture(15, scenePattern);
+ SetInt(seam, decodeProgram, "motionMode", 0);
+ seam.SetViewport(0, 0, Size, Size);
+ seam.SetDepthTest(false);
+ seam.SetDepthMask(false);
+ seam.SetCullFace(false);
+ seam.SetBlend(false, EnumBlendMode.Standard);
+ seam.DrawFullscreenTriangle();
+ }
+
+ ///
+ /// Both history slots to a flat seed, so a resolve starts from known content on either
+ /// route. Inside a frame: a clear between frames is a no-op on this seam.
+ ///
+ public void SeedHistory()
+ {
+ VulkanDevice seam = Seam;
+ for (int parity = 0; parity < 2; parity++)
+ {
+ FrameBufferRef slot = History(parity);
+ seam.BindFramebuffer(slot.FboId);
+ seam.SetDrawBuffers(slot.FboId, 0b111);
+ seam.ClearColor(0, 0.9f, 0.1f, 0.4f, 1f);
+ seam.ClearColor(1, 0.4f, 0.9f, 0.1f, 1f);
+ seam.ClearColor(2, 12.5f, 0f, 0f, 1f);
+ }
+ seam.BindFramebuffer(Primary.FboId);
+ seam.SetDrawBuffers(Primary.FboId, 0b011);
+ }
+
+ /// The sharpen target to a flat seed, so "the pass wrote something" is checkable.
+ public void SeedSharpen()
+ {
+ VulkanDevice seam = Seam;
+ seam.BindFramebuffer(Sharpen.FboId);
+ seam.SetDrawBuffers(Sharpen.FboId, 0b1);
+ seam.ClearColor(0, 0.05f, 0.95f, 0.55f, 1f);
+ seam.BindFramebuffer(Primary.FboId);
+ seam.SetDrawBuffers(Primary.FboId, 0b011);
+ }
+
+ /// TAA on, with the jitter window open or closed, and no sharpen pass.
+ public void EnableTaa(bool jitterActive)
+ {
+ OptimumConfig.Taa = true;
+ OptimumConfig.TaaSharpness = 0f;
+ OptimumTemporalFrame frame = OptimumTemporal.Frame;
+ frame.JitterSequencePx.X = 0.25f;
+ frame.JitterSequencePx.Y = -0.375f;
+ frame.JitterActive = jitterActive;
+ AdvanceTemporalFrame();
+ AdvanceTemporalFrame();
+ frame.JitterActive = jitterActive;
+ }
+
+ ///
+ /// One frame of the temporal contract with the jitter pinned to
+ /// of , so two runs of the same length are comparable whatever
+ /// the global frame counter is at.
+ ///
+ public void AdvanceTemporalFrame(int phase)
+ {
+ AdvanceTemporalFrame();
+ OptimumTemporalFrame frame = OptimumTemporal.Frame;
+ (float x, float y) = JitterPhases[phase % JitterPhases.Length];
+ frame.JitterSequencePx.X = x;
+ frame.JitterSequencePx.Y = y;
+ // The setter is what copies the sequence into the applied jitter.
+ frame.JitterActive = true;
+ }
+
+ ///
+ /// One frame of the temporal contract: the camera and the projection captured, so the
+ /// sky-motion and resolve passes have a previous view to reproject through.
+ ///
+ public void AdvanceTemporalFrame()
+ {
+ OptimumTemporalFrame frame = OptimumTemporal.Frame;
+ bool jitter = frame.JitterActive;
+ frame.Advance(16f, Size, Size, 1f, 0.1f, 100f, 70f, uniforms);
+ double[] projection = Mat4d.Perspective(Mat4d.Create(), 70.0 * Math.PI / 180.0, 1.0, 0.1, 100.0);
+ double[] view = Mat4d.Identity(Mat4d.Create());
+ frame.RecordProjection(EnumTemporalView.World, projection);
+ frame.CaptureCamera(view, view);
+ frame.JitterActive = jitter;
+ }
+
+ // ---------------------------------------------------------------- readback
+
+ public byte[] ReadScene() => ReadAttachmentZero(Primary.FboId, Size, Size);
+
+ public byte[] ReadGlow() => Decode(Primary.ColorTextureIds[1], Size, Size, motion: false);
+
+ public byte[] ReadMotion() => Decode(Primary.ColorTextureIds[2], Size, Size, motion: true);
+
+ public byte[] ReadHistoryColor(int parity) => Decode(History(parity).ColorTextureIds[0], Size, Size, motion: false);
+
+ public byte[] ReadHistoryGlow(int parity) => Decode(History(parity).ColorTextureIds[1], Size, Size, motion: false);
+
+ ///
+ /// The history's linear depth, through the motion decode: it is an R32F in view-space
+ /// metres, which the clamping colour decode would flatten to white everywhere.
+ ///
+ public byte[] ReadHistoryDepth(int parity) => Decode(History(parity).ColorTextureIds[2], Size, Size, motion: true);
+
+ public byte[] ReadSharpen() => Decode(Sharpen.ColorTextureIds[0], Size, Size, motion: false);
+
+ /// One post target's colour 0, decoded at that target's own size.
+ public byte[] ReadPostTarget(int index)
+ {
+ FrameBufferRef target = buffers[index];
+ return Decode(target.ColorTextureIds[0], target.Width, target.Height, motion: false);
+ }
+
+ private unsafe byte[] ReadAttachmentZero(int framebufferId, int width, int height)
+ {
+ var pixels = new byte[width * height * 4];
+ fixed (byte* destination = pixels)
+ {
+ Seam.BindFramebuffer(framebufferId);
+ Seam.ReadDefaultFramebuffer(0, 0, width, height, (IntPtr)destination);
+ }
+ return pixels;
+ }
+
+ ///
+ /// Any attachment through an RGBA8 copy of its own size, because the seam's readback is
+ /// four bytes per pixel from attachment 0. The motion mode encodes the vector into the two
+ /// low channels so a difference in it cannot hide behind a clamp.
+ ///
+ private byte[] Decode(int textureId, int width, int height, bool motion)
+ {
+ VulkanDevice seam = Seam;
+ int framebuffer = DecodeFramebuffer(width, height);
+ seam.BindFramebuffer(framebuffer);
+ seam.ClearColor(0, 0f, 0f, 0f, 1f);
+ seam.UseProgram(decodeProgram);
+ seam.SetSamplerUnit(decodeProgram, "source", 15);
+ seam.BindTexture(15, textureId);
+ SetInt(seam, decodeProgram, "motionMode", motion ? 1 : 0);
+ seam.SetViewport(0, 0, width, height);
+ seam.SetDepthTest(false);
+ seam.SetDepthMask(false);
+ seam.SetCullFace(false);
+ seam.SetBlend(false, EnumBlendMode.Standard);
+ seam.DrawFullscreenTriangle();
+ return ReadAttachmentZero(framebuffer, width, height);
+ }
+
+ /// The RGBA8 decode target of one size, made once.
+ private int DecodeFramebuffer(int width, int height)
+ {
+ if (width == Size && height == Size) return decodeFramebuffer;
+ if (decodeFramebuffers.TryGetValue((width, height), out int existing)) return existing;
+
+ int texture = Seam.CreateTexture2D(width, height, EnumTextureInternalFormat.Rgba8,
+ EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);
+ int framebuffer = Seam.CreateFramebuffer(width, height);
+ Seam.AttachTexture(framebuffer, EnumFramebufferAttachment.ColorAttachment0, texture, 0);
+ Seam.SetDrawBuffers(framebuffer, 0b1);
+ decodeFramebuffers[(width, height)] = framebuffer;
+ return framebuffer;
+ }
+
+ private static void SetInt(VulkanDevice seam, int program, string name, int value)
+ {
+ int location = seam.GetUniformLocation(program, name);
+ if (location >= 0) seam.SetUniform(program, location, value);
+ }
+
+ // ------------------------------------------------------------------- fixture
+
+ private void BuildTargets()
+ {
+ VulkanDevice seam = Seam;
+
+ Primary = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = new[]
+ {
+ Texture(EnumTextureInternalFormat.Rgba8),
+ Texture(EnumTextureInternalFormat.Rgba8),
+ Texture(EnumTextureInternalFormat.Rgba16f),
+ },
+ DepthTextureId = seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.DepthComponent32,
+ EnumTexturePixelFormat.DepthComponent, IntPtr.Zero, false),
+ };
+ for (int slot = 0; slot < 3; slot++)
+ {
+ seam.AttachTexture(Primary.FboId,
+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot),
+ Primary.ColorTextureIds[slot], 0);
+ }
+ seam.AttachTexture(Primary.FboId, EnumFramebufferAttachment.DepthAttachment, Primary.DepthTextureId, 0);
+ Assert.True(seam.CheckFramebufferComplete(Primary.FboId, out string primaryStatus), primaryStatus);
+
+ // The Transparent target as the client leaves it once the OIT renderer has replaced
+ // attachment 0 with its reveal target and attached the accumulation array's three
+ // layers at 3, 4 and 5. ColorTextureIds keeps the vanilla ids, which is what the
+ // merge binds as accumulation, revealage and in-glow.
+ oitReveal = Texture(EnumTextureInternalFormat.Rgba8);
+ oitAccumulation = seam.CreateTexture2DArray(Size, Size, 3,
+ EnumTextureInternalFormat.Rgba16f, EnumTexturePixelFormat.Rgba);
+ Transparent = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = new[]
+ {
+ Seeded(0.7f),
+ Seeded(0.35f),
+ Seeded(0.2f),
+ },
+ };
+ seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment0, oitReveal, 0);
+ seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment1,
+ Transparent.ColorTextureIds[1], 0);
+ seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment2,
+ Transparent.ColorTextureIds[2], 0);
+ seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment3, oitAccumulation, 0);
+ seam.AttachTexture(Transparent.FboId, EnumFramebufferAttachment.ColorAttachment4, oitAccumulation, 1);
+ seam.AttachTexture(Transparent.FboId, (EnumFramebufferAttachment)36069, oitAccumulation, 2);
+ Assert.True(seam.CheckFramebufferComplete(Transparent.FboId, out string status), status);
+
+ for (int i = 0; i <= 24; i++) buffers.Add(null!);
+ buffers[0] = Primary;
+ buffers[1] = Transparent;
+ // The post chain's targets at the sizes and formats SetupDefaultFrameBuffers builds
+ // them at: the bloom ping-pongs at half and quarter resolution, find-bright, god rays
+ // and Luma at full, and the blurred vanilla-SSAO target the final composition binds.
+ buffers[2] = SingleTarget(Size / 2, Size / 2, EnumTextureInternalFormat.Rgba8);
+ buffers[3] = SingleTarget(Size / 2, Size / 2, EnumTextureInternalFormat.Rgba8);
+ buffers[4] = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba16f);
+ buffers[7] = SingleTarget(Size / 2, Size / 2, EnumTextureInternalFormat.Rgba16f);
+ buffers[8] = SingleTarget(Size / 4, Size / 4, EnumTextureInternalFormat.Rgba8);
+ buffers[9] = SingleTarget(Size / 4, Size / 4, EnumTextureInternalFormat.Rgba8);
+ buffers[10] = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba16f);
+ buffers[14] = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba8);
+ buffers[19] = HistoryTarget();
+ buffers[20] = HistoryTarget();
+ Sharpen = SingleTarget(Size, Size, EnumTextureInternalFormat.Rgba16f);
+ buffers[21] = Sharpen;
+
+ scenePattern = Seeded(0.45f);
+ decodeTarget = Texture(EnumTextureInternalFormat.Rgba8);
+ decodeFramebuffer = seam.CreateFramebuffer(Size, Size);
+ seam.AttachTexture(decodeFramebuffer, EnumFramebufferAttachment.ColorAttachment0, decodeTarget, 0);
+ seam.SetDrawBuffers(decodeFramebuffer, 0b1);
+ }
+
+ private int Texture(EnumTextureInternalFormat format) =>
+ Seam.CreateTexture2D(Size, Size, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false);
+
+ /// A texture with a per-pixel pattern around , so a difference shows.
+ private unsafe int Seeded(float level)
+ {
+ var pixels = new byte[Size * Size * 4];
+ for (int y = 0; y < Size; y++)
+ {
+ for (int x = 0; x < Size; x++)
+ {
+ int i = (y * Size + x) * 4;
+ pixels[i] = (byte)Math.Clamp(level * 255f + x * 3, 0, 255);
+ pixels[i + 1] = (byte)Math.Clamp(level * 255f + y * 5, 0, 255);
+ pixels[i + 2] = (byte)Math.Clamp(level * 255f + ((x ^ y) & 7) * 9, 0, 255);
+ pixels[i + 3] = (byte)Math.Clamp(level * 255f, 0, 255);
+ }
+ }
+ fixed (byte* data = pixels)
+ {
+ return Seam.CreateTexture2D(Size, Size, EnumTextureInternalFormat.Rgba8,
+ EnumTexturePixelFormat.Rgba, (IntPtr)data, false);
+ }
+ }
+
+ private FrameBufferRef SingleTarget(int width, int height, EnumTextureInternalFormat format)
+ {
+ var target = new FrameBufferRef
+ {
+ Width = width,
+ Height = height,
+ FboId = Seam.CreateFramebuffer(width, height),
+ ColorTextureIds = new[]
+ {
+ Seam.CreateTexture2D(width, height, format, EnumTexturePixelFormat.Rgba, IntPtr.Zero, false),
+ },
+ };
+ Seam.AttachTexture(target.FboId, EnumFramebufferAttachment.ColorAttachment0, target.ColorTextureIds[0], 0);
+ Seam.SetDrawBuffers(target.FboId, 0b1);
+ return target;
+ }
+
+ /// A TAA history slot: colour, aux and the linear-depth R32F, as the platform builds them.
+ private FrameBufferRef HistoryTarget()
+ {
+ var target = new FrameBufferRef
+ {
+ Width = Size,
+ Height = Size,
+ FboId = Seam.CreateFramebuffer(Size, Size),
+ ColorTextureIds = new[]
+ {
+ Texture(EnumTextureInternalFormat.Rgba16f),
+ Texture(EnumTextureInternalFormat.Rgba8),
+ Seam.CreateTexture2DRaw(Size, Size, 0x822E, IntPtr.Zero, 0),
+ },
+ };
+ for (int slot = 0; slot < 3; slot++)
+ {
+ Seam.AttachTexture(target.FboId,
+ (EnumFramebufferAttachment)((int)EnumFramebufferAttachment.ColorAttachment0 + slot),
+ target.ColorTextureIds[slot], 0);
+ }
+ Seam.SetDrawBuffers(target.FboId, 0b111);
+ return target;
+ }
+
+ private void LinkPrograms()
+ {
+ VulkanDevice seam = Seam;
+ ShaderCorpus.ShaderVariant variant = TaaVariant();
+
+ var compose = new ShaderProgramTransparentcompose { PassName = "transparentcompose" };
+ Link(seam, compose, "transparentcompose", variant, Array.Empty());
+ var skyMotion = new ShaderProgram { PassName = "taa-skymotion" };
+ Link(seam, skyMotion, "taa-skymotion", variant, new[]
+ {
+ "taaRenderSize", "taaJitterPx", "taaInvViewProjJittered", "taaPrevViewProj", "taaCloudReactive",
+ });
+ var sharpen = new ShaderProgram { PassName = "taa-sharpen" };
+ Link(seam, sharpen, "taa-sharpen", variant, new[] { "inputTexelSize", "sharpness" });
+ var resolve = new ShaderProgram { PassName = "taa-resolve" };
+ Link(seam, resolve, "taa-resolve", variant, new[]
+ {
+ "renderSize", "jitterPx", "invViewProjJittered", "prevViewProj", "viewMatrix",
+ "cameraDelta", "resetHistory", "blendAlpha", "varianceGamma",
+ });
+ var blit = new ShaderProgramBlit { PassName = "blit" };
+ Link(seam, blit, "blit", variant, Array.Empty());
+
+ // The chain's tail: find-bright, the blur ladder, god rays, the FXAA luma prepass and
+ // the final composition, with every uniform the OpenGL body sets on them registered
+ // so the old route can run too.
+ var findbright = new ShaderProgramFindbright { PassName = "findbright" };
+ Link(seam, findbright, "findbright", variant, new[] { "ambientBloomLevel", "extraBloom" });
+ var blur = new ShaderProgramBlur { PassName = "blur" };
+ Link(seam, blur, "blur", variant, new[] { "frameSize", "isVertical" });
+ var godrays = new ShaderProgramGodrays { PassName = "godrays" };
+ Link(seam, godrays, "godrays", variant, new[]
+ {
+ "invFrameSizeIn", "maxGodRaySamples", "sunPosScreenIn", "sunPos3dIn",
+ "playerViewVector", "dusk", "iGlobalTimeIn",
+ });
+ var luma = new ShaderProgramLuma { PassName = "luma" };
+ Link(seam, luma, "luma", variant, Array.Empty());
+ var final = new ShaderProgramFinal { PassName = "final" };
+ Link(seam, final, "final", variant, new[]
+ {
+ "ambientBloomLevel", "optimumSsaoInScene", "optimumAoDebug", "invFrameSizeIn",
+ "gammaLevel", "extraGamma", "contrastLevel", "brightnessLevel", "sepiaLevel",
+ "windWaveCounter", "glitchEffectStrength", "sunPosScreenIn", "sunPos3dIn",
+ "playerViewVector", "damageVignetting", "damageVignettingSide", "frostVignetting",
+ });
+
+ ShaderPrograms.Transparentcompose = compose;
+ ShaderPrograms.TaaSkyMotion = skyMotion;
+ ShaderPrograms.TaaResolve = resolve;
+ ShaderPrograms.TaaSharpen = sharpen;
+ ShaderPrograms.Blit = blit;
+ ShaderPrograms.Findbright = findbright;
+ ShaderPrograms.Blur = blur;
+ ShaderPrograms.Godrays = godrays;
+ ShaderPrograms.Luma = luma;
+ ShaderPrograms.Final = final;
+
+ decodeProgram = LinkDecode(seam);
+ }
+
+ /// TAA on without the SSAO G-buffer: the motion attachment at colour 2.
+ internal static ShaderCorpus.ShaderVariant TaaVariant()
+ {
+ foreach (ShaderCorpus.ShaderVariant candidate in ShaderCorpus.Variants())
+ {
+ if (candidate.Name == "taa-no-ssao") return candidate;
+ }
+ throw new InvalidOperationException("the corpus has no taa-no-ssao variant");
+ }
+
+ private static void Link(VulkanDevice seam, ShaderProgramBase program, string name,
+ ShaderCorpus.ShaderVariant variant, string[] uniforms)
+ {
+ List stages = ShaderCorpus.BuildProgram(
+ name, ShaderCorpus.LoadShaderFiles(), ShaderCorpus.LoadIncludes(), variant);
+
+ var linked = new LinkedProgram { PassName = name };
+ foreach (ShaderStageSource stage in stages)
+ {
+ var shader = new LinkedShader
+ {
+ Type = stage.Stage,
+ Code = stage.Code,
+ PrefixCode = stage.PrefixCode,
+ };
+ Assert.True(seam.CompileShader(shader));
+ if (stage.Stage == EnumShaderType.VertexShader) linked.VertexShader = shader;
+ else if (stage.Stage == EnumShaderType.FragmentShader) linked.FragmentShader = shader;
+ }
+
+ int id = seam.LinkProgram(linked);
+ Assert.True(id > 0, seam.GetError() ?? "link failed");
+ program.ProgramId = id;
+ foreach (string uniform in uniforms)
+ {
+ int location = seam.GetUniformLocation(id, uniform);
+ Assert.True(location != -1, name + " has no location for " + uniform);
+ program.uniformLocations[uniform] = location;
+ }
+ }
+
+ /// The readback helper's own program: any attachment into RGBA8.
+ private static int LinkDecode(VulkanDevice seam)
+ {
+ const string vertex = @"#version 330 core
+out vec2 uv;
+void main(void)
+{
+ vec2 position = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
+ uv = position;
+ gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0);
+}
+";
+ const string fragment = @"#version 330 core
+uniform sampler2D source;
+uniform int motionMode;
+layout(location = 0) out vec4 outColor;
+void main(void)
+{
+ vec4 texel = texelFetch(source, ivec2(gl_FragCoord.xy), 0);
+ if (motionMode != 0) {
+ outColor = vec4(
+ clamp(texel.r / 32.0 * 0.5 + 0.5, 0.0, 1.0),
+ clamp(texel.g / 32.0 * 0.5 + 0.5, 0.0, 1.0),
+ clamp(texel.b, 0.0, 1.0),
+ clamp(texel.a, 0.0, 1.0));
+ return;
+ }
+ outColor = clamp(texel, 0.0, 1.0);
+}
+";
+ var linked = new LinkedProgram { PassName = "native-post-decode" };
+ var vertexShader = new LinkedShader { Type = EnumShaderType.VertexShader, Code = vertex, PrefixCode = "" };
+ var fragmentShader = new LinkedShader { Type = EnumShaderType.FragmentShader, Code = fragment, PrefixCode = "" };
+ Assert.True(seam.CompileShader(vertexShader), seam.GetError() ?? "decode vertex shader");
+ Assert.True(seam.CompileShader(fragmentShader), seam.GetError() ?? "decode fragment shader");
+ linked.VertexShader = vertexShader;
+ linked.FragmentShader = fragmentShader;
+ int id = seam.LinkProgram(linked);
+ Assert.True(id > 0, seam.GetError() ?? "decode link failed");
+ return id;
+ }
+
+ /// The platform's frame buffers, motion attachment and TAA readiness, as the setup leaves them.
+ private void InstallState()
+ {
+ typeof(ClientPlatformWindows).GetField("frameBuffers", Hidden)!.SetValue(Platform, buffers);
+ typeof(ClientPlatformWindows).GetField("ssaaLevel", Hidden)!.SetValue(Platform, 1f);
+ Platform.SetOptimumMotionAttachmentIndex(2);
+ typeof(ClientPlatformWindows).GetField("optimumTaaTargetsReady", Hidden)!.SetValue(Platform, true);
+
+ FieldInfo reveal = typeof(SystemRenderOITLayers).GetField("revealTextureId", HiddenStatic)!;
+ FieldInfo accumulation = typeof(SystemRenderOITLayers).GetField("accumTextureId", HiddenStatic)!;
+ oitRevealBefore = reveal.GetValue(null);
+ oitAccumBefore = accumulation.GetValue(null);
+ reveal.SetValue(null, oitReveal);
+ accumulation.SetValue(null, oitAccumulation);
+
+ // The seeds the comparisons quote, read once through the same decode the tests use.
+ Platform.BeginFrame();
+ SeedFrame();
+ SeedHistory();
+ SeedSharpen();
+ SceneSeed = ReadScene();
+ GlowSeed = ReadGlow();
+ MotionSeed = ReadMotion();
+ HistorySeedColor = ReadHistoryColor(0);
+ SharpenSeed = ReadSharpen();
+ Platform.EndFrame();
+ }
+ }
+
+ // ---------------------------------------------------------------- native shaders
+
+ /// The manifest of the programs the chain's two native passes and the resolve use.
+ private static readonly Lazy<(string Directory, string Reason)> NativeManifest = new(BuildNativeShaders);
+
+ private static (string, string) BuildNativeShaders()
+ {
+ if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return ("", reason);
+ using (compiler)
+ {
+ var builder = new NativeShaderBuilder(compiler!);
+ var merged = new NativeShaderBuildResult();
+ merged.Manifest.Toolchain = compiler!.Identity;
+ string source = Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk");
+ foreach (string program in Programs)
+ {
+ NativeShaderBuildResult one = builder.Build(source, program);
+ merged.Errors.AddRange(one.Errors);
+ merged.Manifest.Programs.AddRange(one.Manifest.Programs);
+ foreach ((string file, byte[] bytes) in one.Files) merged.Files[file] = bytes;
+ }
+ if (!merged.Success) return ("", string.Join("\n", merged.Errors));
+
+ string root = Path.Combine(Path.GetTempPath(), "optimum-native-post-shaders-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ NativeShaderBuilder.Write(merged, root);
+ return (Path.Combine(root, NativeShaderManifest.DirectoryName), "");
+ }
+ }
+}
diff --git a/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs
new file mode 100644
index 00000000..54e53570
--- /dev/null
+++ b/Optimum.Render.Vulkan.Tests/NativeShaderParityTests.cs
@@ -0,0 +1,555 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Optimum.Render.Vulkan.Shaders;
+using Vintagestory.API.Client;
+using Xunit;
+
+namespace Optimum.Render.Vulkan.Tests;
+
+///
+/// Static parity between every native program in sources/shaders-vk and the GLSL 330 program it
+/// replaces (docs/vulkan.md). Data-driven over the tree: a family stage adds
+/// shaders, never test code.
+///
+/// The native side is the manifest the offline compiler's library entry point
+/// () produces for the tree. The GLSL 330 side is the program
+/// builds from the effective sources (sources/shaders override, else
+/// the vanilla asset) with the prefix defines of the matching variant. No GPU is needed.
+///
+public sealed class NativeShaderParityTests
+{
+ private static string SourceDirectory => Path.Combine(ShaderCorpus.RepositoryRoot, "sources", "shaders-vk");
+
+ private static readonly Lazy<(NativeShaderBuildResult? Result, string Reason)> Built = new(() =>
+ {
+ if (!NativeShaderTree.TryCreateCompiler(out ShaderCompiler? compiler, out string reason)) return (null, reason);
+ using (compiler)
+ {
+ return (new NativeShaderBuilder(compiler!).Build(SourceDirectory), "");
+ }
+ });
+
+ public static IEnumerable