diff --git a/project.cpp b/project.cpp index a7c3272..0395bbf 100644 --- a/project.cpp +++ b/project.cpp @@ -125,6 +125,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("CompilerWarning").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("ShaderVariantBuild").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("VariantId").Dependencies({ CrafterBuildLib.get() }); diff --git a/tests/ShaderCompile/fixture/variant.frag b/tests/ShaderCompile/fixture/variant.frag new file mode 100644 index 0000000..7d0178c --- /dev/null +++ b/tests/ShaderCompile/fixture/variant.frag @@ -0,0 +1,15 @@ +#version 450 + +// Compiled twice by the ShaderCompile test: once as is, once with VARIANT_RED +// and VARIANT_ALPHA=0.5 defined. The red branch does not compile unless the +// NAME=VALUE define arrives, and the two outputs only differ if the bare one +// does. +layout(location = 0) out vec4 outColor; + +void main() { +#ifdef VARIANT_RED + outColor = vec4(1.0, 0.0, 0.0, VARIANT_ALPHA); +#else + outColor = vec4(0.0, 1.0, 0.0, 1.0); +#endif +} diff --git a/tests/ShaderCompile/main.cpp b/tests/ShaderCompile/main.cpp index 20221d2..1ead9ca 100644 --- a/tests/ShaderCompile/main.cpp +++ b/tests/ShaderCompile/main.cpp @@ -8,7 +8,10 @@ using namespace Crafter; // Compile a tiny GLSL fragment shader directly via the Shader API. Verifies // the SPIR-V output lands in the target dir and starts with the SPIR-V magic -// number, without dragging in the full Build() pipeline. +// number, without dragging in the full Build() pipeline. The second half +// registers one source twice, with and without defines, and checks that the +// variant gets its own output, its own staleness and the other #ifdef branch +// (tests/ShaderVariantBuild drives the same thing through Build()). int main() { fs::path src = fs::current_path() / "tests" / "ShaderCompile" / "fixture" / "triangle.frag"; if (!fs::exists(src)) { @@ -52,5 +55,75 @@ int main() { std::println(std::cerr, "Shader::Check returned false on an up-to-date spv"); return 1; } + + // One source registered twice: the second entry carries defines and a + // variant name. It must land in its own file instead of overwriting the + // first, and it must take the other #ifdef branch. Crafter.Graphics ships + // each UI shader in a descriptor-heap and a descriptor-set flavour this + // way; if the variant were dropped from the output name, whichever compile + // finished last would silently win. + fs::path variantSrc = fs::current_path() / "tests" / "ShaderCompile" / "fixture" / "variant.frag"; + Shader plain(fs::path(variantSrc), "main", ShaderType::Fragment); + Shader red(fs::path(variantSrc), "main", ShaderType::Fragment); + red.defines = {"VARIANT_RED", "VARIANT_ALPHA=0.5"}; + red.variant = "red"; + + if (plain.OutputName() != "variant.spv" || red.OutputName() != "variant.red.spv") { + std::println(std::cerr, "unexpected output names: {} and {}", plain.OutputName().string(), red.OutputName().string()); + return 1; + } + for (const Shader* each : {&plain, &red}) { + // Staleness is per output, not per source: while only variant.spv + // exists, the variant is still due. A Check() that ignored the + // variant would call it up to date here, and Build() would then + // never compile a variant added to an already built project. + if (each == &red && red.Check(outDir)) { + std::println(std::cerr, "Shader::Check called the variant up to date before it was ever compiled"); + return 1; + } + std::string variantErr = each->Compile(outDir, includeDirs); + if (!variantErr.empty()) { + std::println(std::cerr, "variant shader compile failed: {}", variantErr); + return 1; + } + if (!each->Check(outDir)) { + std::println(std::cerr, "Shader::Check returned false for {}", each->OutputName().string()); + return 1; + } + } + + // Malformed defines and variants are refused by name. Left to glslang, an + // empty macro name surfaces as "-1:" with no file, and a variant + // with a separator as a failed open after the whole compile. + for (const char* bad : {"", "=1", "A\nB"}) { + Shader malformed(fs::path(variantSrc), "main", ShaderType::Fragment); + malformed.defines = {bad}; + malformed.variant = "malformed"; + if (!malformed.Compile(outDir, includeDirs).contains("invalid shader define")) { + std::println(std::cerr, "a malformed define was not rejected by name"); + return 1; + } + } + Shader nested(fs::path(variantSrc), "main", ShaderType::Fragment); + nested.variant = "sub/red"; + if (!nested.Compile(outDir, includeDirs).contains("path separator")) { + std::println(std::cerr, "a variant with a path separator was not rejected by name"); + return 1; + } + + auto readAll = [](const fs::path& file) { + std::ifstream stream(file, std::ios::binary); + return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + }; + std::string plainSpv = readAll(outDir / "variant.spv"); + std::string redSpv = readAll(outDir / "variant.red.spv"); + if (plainSpv.empty() || redSpv.empty()) { + std::println(std::cerr, "a variant output is missing or empty"); + return 1; + } + if (plainSpv == redSpv) { + std::println(std::cerr, "both variants compiled to the same SPIR-V: the defines did not reach the preprocessor"); + return 1; + } return 0; } diff --git a/tests/ShaderVariantBuild/fixture/main.cpp b/tests/ShaderVariantBuild/fixture/main.cpp new file mode 100644 index 0000000..97eb9af --- /dev/null +++ b/tests/ShaderVariantBuild/fixture/main.cpp @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +int main() { + return 0; +} diff --git a/tests/ShaderVariantBuild/fixture/tint.frag b/tests/ShaderVariantBuild/fixture/tint.frag new file mode 100644 index 0000000..c227813 --- /dev/null +++ b/tests/ShaderVariantBuild/fixture/tint.frag @@ -0,0 +1,12 @@ +#version 450 + +// Built twice by the ShaderVariantBuild test: as is, and with TINT_RED defined. +layout(location = 0) out vec4 outColor; + +void main() { +#if TINT_RED + outColor = vec4(1.0, 0.0, 0.0, 1.0); +#else + outColor = vec4(0.0, 1.0, 0.0, 1.0); +#endif +} diff --git a/tests/ShaderVariantBuild/main.cpp b/tests/ShaderVariantBuild/main.cpp new file mode 100644 index 0000000..517b99c --- /dev/null +++ b/tests/ShaderVariantBuild/main.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// One shader source can be registered more than once, each entry with its own +// defines and a variant that names its output ("tint.spv", "tint.red.spv"). +// Build() compiles the entries in parallel into one directory, which is where +// the feature can go wrong in ways the Shader API alone does not show: +// +// - both entries must land in the bin dir, with different contents; +// - an entry added later must still be compiled when the other one's output +// is already there and up to date; +// - two entries that would write the same file (defines set, variant +// forgotten) used to race silently: the file held whichever compile +// finished last, the build reported success, and Check() called both up +// to date from then on. That has to be a build error naming the cause. +// +// The fixture's `#if TINT_RED` also pins that a bare define is defined to 1, +// like the compiler's -D: an empty definition would not compile. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + // Shader outputs are compared by mtime against the source, and the test + // adds an entry between passes, so work on a fresh copy outside the repo. + fs::path StageFixture() { + fs::path source = fs::current_path() / "tests" / "ShaderVariantBuild" / "fixture"; + fs::path staged = fs::temp_directory_path() / "crafter-build-shader-variant-build"; + fs::remove_all(staged); + fs::copy(source, staged, fs::copy_options::recursive); + return staged; + } + + Configuration MakeApp(const fs::path& staged) { + Configuration app; + app.path = staged; + app.name = "tinted"; + app.outputName = "tinted"; + app.target = HostTarget(); + app.type = ConfigurationType::Executable; + std::array ifaces = {}; + std::array impls = { "main" }; + app.GetInterfacesAndImplementations(ifaces, impls); + return app; + } + + Shader& AddTint(Configuration& app, const fs::path& staged) { + return app.shaders.emplace_back(staged / "tint.frag", std::string("main"), ShaderType::Fragment); + } + + BuildResult BuildOnce(Configuration& app) { + // A fresh depResults per pass: the map memoizes a Configuration's + // build for the duration of one pass. + std::unordered_map> depResults; + std::mutex depMutex; + return Build(app, depResults, depMutex); + } + + std::string ReadAll(const fs::path& file) { + std::ifstream stream(file, std::ios::binary); + return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); + } +} + +int main() { + const fs::path staged = StageFixture(); + + // Pass 1: the plain entry alone. + Configuration app = MakeApp(staged); + AddTint(app, staged); + BuildResult first = BuildOnce(app); + Check(first.result.empty(), std::format("the plain entry builds: {}", first.result)); + const fs::path plainSpv = app.BinDir() / "tint.spv"; + const fs::path redSpv = app.BinDir() / "tint.red.spv"; + Check(fs::exists(plainSpv), "tint.spv is in the bin dir"); + Check(!fs::exists(redSpv), "no variant output before the variant is registered"); + + // Pass 2: the variant is added while tint.spv is already up to date. It + // has its own staleness check and must be compiled, into its own file. + Shader& red = AddTint(app, staged); + red.defines = {"TINT_RED"}; + red.variant = "red"; + BuildResult second = BuildOnce(app); + Check(second.result.empty(), std::format("plain + variant builds: {}", second.result)); + Check(fs::exists(redSpv), "tint.red.spv is in the bin dir"); + const std::string plainBytes = ReadAll(plainSpv); + const std::string redBytes = ReadAll(redSpv); + Check(!plainBytes.empty() && !redBytes.empty(), "both outputs have content"); + Check(plainBytes != redBytes, "the variant took the other #if branch"); + + // Pass 3: the same define without a variant. Both entries would write + // tint.spv, so the build has to stop and say so, and must leave the file + // from pass 2 alone. + Configuration clash = MakeApp(staged); + AddTint(clash, staged); + Shader& unnamed = AddTint(clash, staged); + unnamed.defines = {"TINT_RED"}; + BuildResult third = BuildOnce(clash); + Check(!third.result.empty(), "two entries with one output name fail the build"); + Check(third.result.contains("tint.spv") && third.result.contains("variant"), std::format("the error names the file and the fix: {}", third.result)); + Check(ReadAll(plainSpv) == plainBytes, "the existing output is not overwritten by the failed build"); + + std::error_code ec; + fs::remove_all(staged, ec); + + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); + return 1; + } + return 0; +}