Crafter.Build/implementations/Crafter.Build-Shader.cpp
Jorijn van der Graaf 653dd0bb9b
Some checks failed
Flatpak / flatpak (push) Failing after 1m12s
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is
- 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>
2026-10-20 20:20:29 +02:00

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 {};
}
}