Some checks failed
Flatpak / flatpak (push) Failing after 1m12s
- Shaders record the files glslang opened (a depfile) and their settings
(source path, stage, entry point, defines, include directories) in the
build directory, and rebuild when an included file or a setting changes,
not only the shader's own source.
- An executable relinks when its link command changed, not only when an
object is newer: a link flag in project.cpp, or a dependency's library
that moved (an ExternalDependency built into a new cache directory). The
command is recorded beside the objects; its flags come from ordered sets,
so the order the dependency threads finish in can't change it.
- An external's newest artifact counts its libDirs and versioned .so names.
- Submodules are checked out at the revisions the fetched commit records,
from the URLs its .gitmodules gives, after every clone, switch, checkout
and pull, so a pinned commit builds against its own submodules, and one
moved by hand is put back; with no .gitmodules, git isn't asked.
- Each external clone's fetch and build hold a file lock: parallel builds
pulled into one clone at once ("cannot lock ref").
- mingw:
- -stdlib=libstdc++ always;
- the std module's source is found through libstdc++.modules.json in the
mingw toolchain's own library directories (the generic lib/ holds the
host's Linux one), asking the compiler once a run;
- a toolchain that isn't there is a build error saying where it looked,
not std::terminate;
- CRAFTER_MINGW_DIR names the prefix, the directory holding
x86_64-w64-mingw32/ and lib/gcc/;
- inside the Flatpak the host's /usr mingw is used.
- RunScript's exit-status check reads as the one range it is.
- Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules,
LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and
ToolchainResolve extended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
11 KiB
C++
230 lines
11 KiB
C++
// SPDX-License-Identifier: LGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#include "SPIRV/GlslangToSpv.h"
|
|
#include "glslang/Public/ShaderLang.h"
|
|
#include "glslang/Public/ResourceLimits.h"
|
|
#include "../lib/DirStackFileIncluder.h"
|
|
module Crafter.Build:Shader_impl;
|
|
import :Shader;
|
|
import :Platform;
|
|
import std;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
EShLanguage ToEShLanguage(Crafter::ShaderType t) {
|
|
switch (t) {
|
|
case Crafter::ShaderType::Vertex: return EShLangVertex;
|
|
case Crafter::ShaderType::TessControl: return EShLangTessControl;
|
|
case Crafter::ShaderType::TessEvaluation: return EShLangTessEvaluation;
|
|
case Crafter::ShaderType::Geometry: return EShLangGeometry;
|
|
case Crafter::ShaderType::Fragment: return EShLangFragment;
|
|
case Crafter::ShaderType::Compute: return EShLangCompute;
|
|
case Crafter::ShaderType::RayGen: return EShLangRayGen;
|
|
case Crafter::ShaderType::Intersect: return EShLangIntersect;
|
|
case Crafter::ShaderType::AnyHit: return EShLangAnyHit;
|
|
case Crafter::ShaderType::ClosestHit: return EShLangClosestHit;
|
|
case Crafter::ShaderType::Miss: return EShLangMiss;
|
|
case Crafter::ShaderType::Callable: return EShLangCallable;
|
|
case Crafter::ShaderType::Task: return EShLangTask;
|
|
case Crafter::ShaderType::Mesh: return EShLangMesh;
|
|
}
|
|
return EShLangVertex;
|
|
}
|
|
|
|
// A path as a Make prerequisite, escaped the way NewestPrerequisite reads it.
|
|
std::string MakeEscaped(const std::string& path) {
|
|
std::string escaped;
|
|
for (const char c : path) {
|
|
if (c == ' ' || c == '\t' || c == '#') escaped += '\\';
|
|
if (c == '$') escaped += '$';
|
|
escaped += c;
|
|
}
|
|
return escaped;
|
|
}
|
|
|
|
std::string ReadFile(const fs::path& file) {
|
|
std::ifstream stream(file, std::ios::binary);
|
|
return std::string(std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
// What a compile's output depends on besides files' contents, one line
|
|
// each: two entries of one source differ here, and an #include searched
|
|
// for from another source directory or in other directories can find
|
|
// another file.
|
|
std::string Settings(const Crafter::Shader& shader, std::span<const fs::path> includeDirs) {
|
|
std::string settings = std::format("source {}\nstage {}\nentry {}\n", fs::absolute(shader.path).string(), static_cast<int>(shader.type), shader.entrypoint);
|
|
for (const std::string& define : shader.defines) settings += std::format("define {}\n", define);
|
|
for (const fs::path& dir : includeDirs) settings += std::format("include {}\n", dir.string());
|
|
return settings;
|
|
}
|
|
|
|
// Where Compile records, for Check, what the output in the bin dir was
|
|
// built from: beside the configuration's objects, as their depfiles are.
|
|
fs::path Record(const Crafter::Shader& shader, const fs::path& buildDir, std::string_view kind) {
|
|
return buildDir / std::format("{}.{}", shader.OutputName().string(), kind);
|
|
}
|
|
}
|
|
|
|
namespace Crafter {
|
|
Shader::Shader(fs::path&& path, std::string&& entrypoint, ShaderType type) : path(std::move(path)), entrypoint(std::move(entrypoint)), type(type) {
|
|
|
|
}
|
|
fs::path Shader::OutputName() const {
|
|
// replace_extension swaps only the last extension, so a stage suffix
|
|
// survives: "ui.comp.glsl" becomes "ui.comp.spv", or "ui.comp.sets.spv"
|
|
// for the variant "sets".
|
|
fs::path name = path.filename();
|
|
name.replace_extension(variant.empty() ? std::string("spv") : std::format("{}.spv", variant));
|
|
return name;
|
|
}
|
|
bool Shader::Check(const fs::path& outputDir, const fs::path& buildDir, std::span<const fs::path> includeDirs) const {
|
|
std::error_code ec;
|
|
const fs::file_time_type built = fs::last_write_time(outputDir / OutputName(), ec);
|
|
if (ec) return false;
|
|
// The source and every file it #included, as the last compile
|
|
// recorded them: without a record, or with an included file since
|
|
// deleted, NewestPrerequisite reads max(), which is stale.
|
|
if (std::max(fs::last_write_time(path), NewestPrerequisite(Record(*this, buildDir, "d"))) >= built) return false;
|
|
return ReadFile(Record(*this, buildDir, "settings")) == Settings(*this, includeDirs);
|
|
}
|
|
std::string Shader::Compile(const fs::path& outputDir, const fs::path& buildDir, std::span<const fs::path> includeDirs) const {
|
|
// The records go first, so a compile that fails leaves the output
|
|
// stale, whatever it wrote.
|
|
const fs::path depFile = Record(*this, buildDir, "d");
|
|
const fs::path settingsFile = Record(*this, buildDir, "settings");
|
|
std::error_code removed;
|
|
fs::remove(depFile, removed);
|
|
fs::remove(settingsFile, removed);
|
|
|
|
EShLanguage glslangType = ToEShLanguage(type);
|
|
glslang::InitializeProcess();
|
|
|
|
// Every error path returns a non-empty string; the caller treats
|
|
// empty as success (see Crafter.Build-Clang.cpp BuildOnce shader
|
|
// worker). Prefixing with the source path is what tells the user
|
|
// which shader actually failed when several compile in parallel.
|
|
auto fail = [&](std::string_view stage, std::string log) {
|
|
glslang::FinalizeProcess();
|
|
std::string out = path.string();
|
|
// Several variants of one source compile in parallel; without the
|
|
// variant the message would not say which of them failed.
|
|
if (!variant.empty()) out += std::format(" ({})", variant);
|
|
out += ": ";
|
|
out += stage;
|
|
if (!log.empty()) {
|
|
out += '\n';
|
|
out += log;
|
|
}
|
|
return out;
|
|
};
|
|
|
|
constexpr EShMessages Messages = static_cast<EShMessages>(EShMsgDefault | EShMsgVulkanRules | EShMsgSpvRules);
|
|
std::ifstream fileStream(path, std::ios::in | std::ios::binary);
|
|
if (!fileStream) {
|
|
return fail("failed to open shader source", {});
|
|
}
|
|
std::ostringstream contents;
|
|
contents << fileStream.rdbuf();
|
|
std::string src = contents.str();
|
|
|
|
std::string pathStr = path.string();
|
|
const char* fileNameList[1] = { pathStr.c_str() };
|
|
const char* shaderSource = src.data();
|
|
const std::int32_t shaderSourceLen = static_cast<std::int32_t>(src.size());
|
|
|
|
// A variant is one component of a file name. Caught here, because a
|
|
// separator would otherwise surface as "failed to open SPIR-V output"
|
|
// after the whole compile.
|
|
if (variant.find_first_of("/\\") != std::string::npos) {
|
|
return fail(std::format("shader variant '{}' must not contain a path separator", variant), {});
|
|
}
|
|
|
|
// One #define line per entry, split like the compiler's -D: "NAME"
|
|
// defines it to 1, "NAME=VALUE" to VALUE. glslang places the preamble
|
|
// ahead of the whole source (it finds #version by scanning first), so
|
|
// line numbers in the user's file are unaffected. It keeps the
|
|
// pointer it is handed instead of copying, so the string is declared
|
|
// here to outlive parse() below.
|
|
std::string preamble;
|
|
for (const std::string& define : defines) {
|
|
const std::size_t eq = define.find('=');
|
|
const std::string name = define.substr(0, eq);
|
|
// An error inside the preamble is reported as "-1:<line>" with no
|
|
// file and no macro name, so reject the malformed ones by name.
|
|
if (name.empty() || define.find_first_of("\r\n") != std::string::npos) {
|
|
return fail(std::format("invalid shader define '{}': expected NAME or NAME=VALUE on one line", define), {});
|
|
}
|
|
preamble += std::format("#define {} {}\n", name, eq == std::string::npos ? std::string("1") : define.substr(eq + 1));
|
|
}
|
|
|
|
glslang::TShader shader(glslangType);
|
|
shader.setStringsWithLengthsAndNames(&shaderSource, &shaderSourceLen, fileNameList, 1);
|
|
if (!preamble.empty()) shader.setPreamble(preamble.c_str());
|
|
shader.setEntryPoint(entrypoint.c_str());
|
|
shader.setSourceEntryPoint(entrypoint.c_str());
|
|
shader.setEnvTarget(glslang::EShTargetSpv, glslang::EShTargetSpv_1_4);
|
|
DirStackFileIncluder includeDir;
|
|
includeDir.pushExternalLocalDirectory(path.parent_path().generic_string());
|
|
for (const fs::path& dir : includeDirs) {
|
|
includeDir.pushExternalLocalDirectory(dir.generic_string());
|
|
}
|
|
|
|
if (!shader.parse(GetDefaultResources(), 100, false, Messages, includeDir)) {
|
|
return fail("GLSL parse failed", std::string(shader.getInfoLog()) + shader.getInfoDebugLog());
|
|
}
|
|
|
|
glslang::TProgram program;
|
|
program.addShader(&shader);
|
|
if (!program.link(Messages)) {
|
|
return fail("GLSL link failed", std::string(program.getInfoLog()) + program.getInfoDebugLog());
|
|
}
|
|
|
|
glslang::TIntermediate* intermediate = program.getIntermediate(glslangType);
|
|
if (!intermediate) {
|
|
// Defensive: parse+link succeeded above, so this should be
|
|
// unreachable. If glslang ever changes that contract we'd
|
|
// rather surface a clear error than dereference null (the
|
|
// pre-fix bug that masqueraded as a silent SIGSEGV).
|
|
return fail("glslang produced no intermediate code", {});
|
|
}
|
|
|
|
spv::SpvBuildLogger logger;
|
|
std::vector<std::uint32_t> spirv;
|
|
glslang::GlslangToSpv(*intermediate, spirv, &logger);
|
|
std::string spvLog = logger.getAllMessages();
|
|
|
|
fs::path filename = OutputName();
|
|
std::ofstream file(outputDir/filename, std::ios::binary);
|
|
if (!file) {
|
|
return fail("failed to open SPIR-V output", (outputDir/filename).string());
|
|
}
|
|
file.write(reinterpret_cast<const char*>(spirv.data()), spirv.size() * sizeof(std::uint32_t));
|
|
|
|
if (!spvLog.empty()) {
|
|
return fail("SPIR-V codegen reported issues", std::move(spvLog));
|
|
}
|
|
|
|
// What Check compares the output against next time: the source and
|
|
// the files it included, as absolute paths so that reading them back
|
|
// doesn't depend on the directory a build runs from, and the settings.
|
|
std::ofstream dep(depFile, std::ios::binary);
|
|
dep << MakeEscaped(fs::absolute(outputDir / filename).string()) << ": " << MakeEscaped(fs::absolute(path).string());
|
|
for (const std::string& included : includeDir.getIncludedFiles()) {
|
|
dep << " \\\n " << MakeEscaped(fs::absolute(included).string());
|
|
}
|
|
dep << '\n';
|
|
dep.close();
|
|
std::ofstream settings(settingsFile, std::ios::binary);
|
|
settings << Settings(*this, includeDirs);
|
|
settings.close();
|
|
if (!dep || !settings) {
|
|
fs::remove(depFile, removed);
|
|
fs::remove(settingsFile, removed);
|
|
return fail("failed to record what the shader was built from", buildDir.string());
|
|
}
|
|
glslang::FinalizeProcess();
|
|
return {};
|
|
}
|
|
}
|