// 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(stream), std::istreambuf_iterator()); } // 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 includeDirs) { std::string settings = std::format("source {}\nstage {}\nentry {}\n", fs::absolute(shader.path).string(), static_cast(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 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 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(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(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:" 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 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(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 {}; } }