Crafter.Build/implementations/Crafter.Build-Shader.cpp

163 lines
7.7 KiB
C++
Raw Normal View History

2026-07-23 01:24:42 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
2025-10-31 16:50:47 +01:00
module;
2026-03-02 21:11:56 +01:00
#include "SPIRV/GlslangToSpv.h"
#include "glslang/Public/ShaderLang.h"
#include "glslang/Public/ResourceLimits.h"
2025-10-31 16:50:47 +01:00
#include "../lib/DirStackFileIncluder.h"
module Crafter.Build:Shader_impl;
import :Shader;
import std;
namespace fs = std::filesystem;
2026-04-27 07:04:42 +02:00
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;
}
}
2025-10-31 16:50:47 +01:00
namespace Crafter {
2026-04-27 07:04:42 +02:00
Shader::Shader(fs::path&& path, std::string&& entrypoint, ShaderType type) : path(std::move(path)), entrypoint(std::move(entrypoint)), type(type) {
2025-10-31 16:50:47 +01:00
feat(shader): per-entry defines and a named output variant Crafter.Graphics has to ship each of its compute shaders in two builds of one source: one that indexes VK_EXT_descriptor_heap and one that reads the same slots from a descriptor set, for drivers without the extension. A Shader was a path, an entry point and a stage, and its output name was derived from the path alone, so one source could only be compiled once. Shader gains `defines`, spelled like the compiler's -D ("NAME" is defined to 1, "NAME=VALUE" to VALUE) and handed to glslang as a preamble, and `variant`, which names the output: "ui.comp.glsl" with variant "sets" becomes "ui.comp.sets.spv". OutputName() is the one place that name is formed; Check, Compile and the forwarding of a dependency's shaders into the consumer's bin dir all use it. Two fields make one mistake easy: defines set, variant forgotten. Both entries then compiled in parallel into the same file, the build reported success with whichever finished last, and Check() called both up to date from then on. The same has always been true of a.vert next to a.frag. Build() now refuses a configuration in which two entries share an output name and says which. A define without a name, or one spanning lines, and a variant with a path separator are rejected by name. Left to glslang the first two surface as "-1:<line>" with no file, and the last as a failed open after the whole compile. Not addressed: Check() still compares only the source's mtime, so a change to the defines, or to an included file, does not recompile (#35). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:50:41 +02:00
}
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;
2025-10-31 16:50:47 +01:00
}
bool Shader::Check(const fs::path& outputDir) const {
feat(shader): per-entry defines and a named output variant Crafter.Graphics has to ship each of its compute shaders in two builds of one source: one that indexes VK_EXT_descriptor_heap and one that reads the same slots from a descriptor set, for drivers without the extension. A Shader was a path, an entry point and a stage, and its output name was derived from the path alone, so one source could only be compiled once. Shader gains `defines`, spelled like the compiler's -D ("NAME" is defined to 1, "NAME=VALUE" to VALUE) and handed to glslang as a preamble, and `variant`, which names the output: "ui.comp.glsl" with variant "sets" becomes "ui.comp.sets.spv". OutputName() is the one place that name is formed; Check, Compile and the forwarding of a dependency's shaders into the consumer's bin dir all use it. Two fields make one mistake easy: defines set, variant forgotten. Both entries then compiled in parallel into the same file, the build reported success with whichever finished last, and Check() called both up to date from then on. The same has always been true of a.vert next to a.frag. Build() now refuses a configuration in which two entries share an output name and says which. A define without a name, or one spanning lines, and a variant with a path separator are rejected by name. Left to glslang the first two surface as "-1:<line>" with no file, and the last as a failed open after the whole compile. Not addressed: Check() still compares only the source's mtime, so a change to the defines, or to an included file, does not recompile (#35). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:50:41 +02:00
fs::path spv = outputDir / OutputName();
2026-05-01 19:16:13 +02:00
return fs::exists(spv) && fs::last_write_time(path) < fs::last_write_time(spv);
2025-10-31 16:50:47 +01:00
}
2026-05-02 21:08:51 +02:00
std::string Shader::Compile(const fs::path& outputDir, std::span<const fs::path> includeDirs) const {
2026-04-27 07:04:42 +02:00
EShLanguage glslangType = ToEShLanguage(type);
2025-10-31 16:50:47 +01:00
glslang::InitializeProcess();
2026-05-01 19:02:14 +02:00
// 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();
feat(shader): per-entry defines and a named output variant Crafter.Graphics has to ship each of its compute shaders in two builds of one source: one that indexes VK_EXT_descriptor_heap and one that reads the same slots from a descriptor set, for drivers without the extension. A Shader was a path, an entry point and a stage, and its output name was derived from the path alone, so one source could only be compiled once. Shader gains `defines`, spelled like the compiler's -D ("NAME" is defined to 1, "NAME=VALUE" to VALUE) and handed to glslang as a preamble, and `variant`, which names the output: "ui.comp.glsl" with variant "sets" becomes "ui.comp.sets.spv". OutputName() is the one place that name is formed; Check, Compile and the forwarding of a dependency's shaders into the consumer's bin dir all use it. Two fields make one mistake easy: defines set, variant forgotten. Both entries then compiled in parallel into the same file, the build reported success with whichever finished last, and Check() called both up to date from then on. The same has always been true of a.vert next to a.frag. Build() now refuses a configuration in which two entries share an output name and says which. A define without a name, or one spanning lines, and a variant with a path separator are rejected by name. Left to glslang the first two surface as "-1:<line>" with no file, and the last as a failed open after the whole compile. Not addressed: Check() still compares only the source's mtime, so a change to the defines, or to an included file, does not recompile (#35). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:50:41 +02:00
// 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);
2026-05-01 19:02:14 +02:00
out += ": ";
out += stage;
if (!log.empty()) {
out += '\n';
out += log;
}
return out;
};
constexpr EShMessages Messages = static_cast<EShMessages>(EShMsgDefault | EShMsgVulkanRules | EShMsgSpvRules);
2025-10-31 16:50:47 +01:00
std::ifstream fileStream(path, std::ios::in | std::ios::binary);
if (!fileStream) {
2026-05-01 19:02:14 +02:00
return fail("failed to open shader source", {});
2025-10-31 16:50:47 +01:00
}
std::ostringstream contents;
contents << fileStream.rdbuf();
std::string src = contents.str();
2026-04-27 07:04:42 +02:00
2026-05-01 19:02:14 +02:00
std::string pathStr = path.string();
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
const char* fileNameList[1] = { pathStr.c_str() };
2026-07-23 01:24:42 +02:00
const char* shaderSource = src.data();
const std::int32_t shaderSourceLen = static_cast<std::int32_t>(src.size());
2026-04-27 07:04:42 +02:00
feat(shader): per-entry defines and a named output variant Crafter.Graphics has to ship each of its compute shaders in two builds of one source: one that indexes VK_EXT_descriptor_heap and one that reads the same slots from a descriptor set, for drivers without the extension. A Shader was a path, an entry point and a stage, and its output name was derived from the path alone, so one source could only be compiled once. Shader gains `defines`, spelled like the compiler's -D ("NAME" is defined to 1, "NAME=VALUE" to VALUE) and handed to glslang as a preamble, and `variant`, which names the output: "ui.comp.glsl" with variant "sets" becomes "ui.comp.sets.spv". OutputName() is the one place that name is formed; Check, Compile and the forwarding of a dependency's shaders into the consumer's bin dir all use it. Two fields make one mistake easy: defines set, variant forgotten. Both entries then compiled in parallel into the same file, the build reported success with whichever finished last, and Check() called both up to date from then on. The same has always been true of a.vert next to a.frag. Build() now refuses a configuration in which two entries share an output name and says which. A define without a name, or one spanning lines, and a variant with a path separator are rejected by name. Left to glslang the first two surface as "-1:<line>" with no file, and the last as a failed open after the whole compile. Not addressed: Check() still compares only the source's mtime, so a change to the defines, or to an included file, does not recompile (#35). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:50:41 +02:00
// 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));
}
2026-04-27 07:04:42 +02:00
glslang::TShader shader(glslangType);
feat(lint): naming reads the AST, deleting the scope heuristic The rule was 125 lines: four std::regex, a hand-rolled {/} scope stack, a cumulative paren-depth counter so a wrapped parameter list would not look like a declaration, a 40-entry keyword denylist, and a "function-shaped line" guess whose own comment conceded it was heuristic. Storage class came from lineStr.contains("static "). It is now ~55 lines that ask clang what kind of declaration each thing is and what encloses it. The three regressions the old version carried special cases for — a call with an inline lambda argument, a bare statement call, a one-liner method — need no handling at all, because a call is not a declaration. Their tests pass unchanged. On this repository the exact version found 42 violations the heuristic had never been able to see, all real: - 37 members of the libclang function-pointer table added two commits ago were PascalCase. The old varDecl regex could not match a declaration whose type is decltype(&f), so they were silently skipped. Renamed to camelCase, which mirrors clang_createIndex -> createIndex more closely anyway. - Crafter.Build-Shader.cpp had a snake_case local, file_name_list, invisible to the heuristic for the same reason (it declares a const char* array). - Four extern "C" declarations of libc functions in tests were reported as badly-named functions. Those are named by the C library, so C language linkage is now an exemption — the same principled test no-char-pointer uses, rather than another denylist entry. New tests cover what the line-based version structurally could not reach: a signature wrapped over several lines, `static` on its own line above the declaration it applies to, and a member versus a local inside a method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:53:26 +02:00
shader.setStringsWithLengthsAndNames(&shaderSource, &shaderSourceLen, fileNameList, 1);
feat(shader): per-entry defines and a named output variant Crafter.Graphics has to ship each of its compute shaders in two builds of one source: one that indexes VK_EXT_descriptor_heap and one that reads the same slots from a descriptor set, for drivers without the extension. A Shader was a path, an entry point and a stage, and its output name was derived from the path alone, so one source could only be compiled once. Shader gains `defines`, spelled like the compiler's -D ("NAME" is defined to 1, "NAME=VALUE" to VALUE) and handed to glslang as a preamble, and `variant`, which names the output: "ui.comp.glsl" with variant "sets" becomes "ui.comp.sets.spv". OutputName() is the one place that name is formed; Check, Compile and the forwarding of a dependency's shaders into the consumer's bin dir all use it. Two fields make one mistake easy: defines set, variant forgotten. Both entries then compiled in parallel into the same file, the build reported success with whichever finished last, and Check() called both up to date from then on. The same has always been true of a.vert next to a.frag. Build() now refuses a configuration in which two entries share an output name and says which. A define without a name, or one spanning lines, and a variant with a path separator are rejected by name. Left to glslang the first two surface as "-1:<line>" with no file, and the last as a failed open after the whole compile. Not addressed: Check() still compares only the source's mtime, so a change to the defines, or to an included file, does not recompile (#35). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:50:41 +02:00
if (!preamble.empty()) shader.setPreamble(preamble.c_str());
2025-10-31 16:50:47 +01:00
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());
2026-05-02 21:08:51 +02:00
for (const fs::path& dir : includeDirs) {
includeDir.pushExternalLocalDirectory(dir.generic_string());
}
2026-04-27 07:04:42 +02:00
if (!shader.parse(GetDefaultResources(), 100, false, Messages, includeDir)) {
2026-05-01 19:02:14 +02:00
return fail("GLSL parse failed", std::string(shader.getInfoLog()) + shader.getInfoDebugLog());
2025-10-31 16:50:47 +01:00
}
2026-04-27 07:04:42 +02:00
2026-05-01 19:02:14 +02:00
glslang::TProgram program;
program.addShader(&shader);
if (!program.link(Messages)) {
2026-05-01 19:02:14 +02:00
return fail("GLSL link failed", std::string(program.getInfoLog()) + program.getInfoDebugLog());
2025-10-31 16:50:47 +01:00
}
2026-04-27 07:04:42 +02:00
glslang::TIntermediate* intermediate = program.getIntermediate(glslangType);
2026-05-01 19:02:14 +02:00
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", {});
2025-10-31 16:50:47 +01:00
}
2026-04-27 07:04:42 +02:00
2025-10-31 16:50:47 +01:00
spv::SpvBuildLogger logger;
std::vector<std::uint32_t> spirv;
glslang::GlslangToSpv(*intermediate, spirv, &logger);
2026-05-01 19:02:14 +02:00
std::string spvLog = logger.getAllMessages();
feat(shader): per-entry defines and a named output variant Crafter.Graphics has to ship each of its compute shaders in two builds of one source: one that indexes VK_EXT_descriptor_heap and one that reads the same slots from a descriptor set, for drivers without the extension. A Shader was a path, an entry point and a stage, and its output name was derived from the path alone, so one source could only be compiled once. Shader gains `defines`, spelled like the compiler's -D ("NAME" is defined to 1, "NAME=VALUE" to VALUE) and handed to glslang as a preamble, and `variant`, which names the output: "ui.comp.glsl" with variant "sets" becomes "ui.comp.sets.spv". OutputName() is the one place that name is formed; Check, Compile and the forwarding of a dependency's shaders into the consumer's bin dir all use it. Two fields make one mistake easy: defines set, variant forgotten. Both entries then compiled in parallel into the same file, the build reported success with whichever finished last, and Check() called both up to date from then on. The same has always been true of a.vert next to a.frag. Build() now refuses a configuration in which two entries share an output name and says which. A define without a name, or one spanning lines, and a variant with a path separator are rejected by name. Left to glslang the first two surface as "-1:<line>" with no file, and the last as a failed open after the whole compile. Not addressed: Check() still compares only the source's mtime, so a change to the defines, or to an included file, does not recompile (#35). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:50:41 +02:00
fs::path filename = OutputName();
2025-10-31 16:50:47 +01:00
std::ofstream file(outputDir/filename, std::ios::binary);
2026-05-01 19:02:14 +02:00
if (!file) {
return fail("failed to open SPIR-V output", (outputDir/filename).string());
}
2025-10-31 16:50:47 +01:00
file.write(reinterpret_cast<const char*>(spirv.data()), spirv.size() * sizeof(std::uint32_t));
2026-05-01 19:02:14 +02:00
if (!spvLog.empty()) {
return fail("SPIR-V codegen reported issues", std::move(spvLog));
}
glslang::FinalizeProcess();
return {};
2025-10-31 16:50:47 +01:00
}
}