commit faaf7fa12094f4aac8cb8e1082565096c005f51c Author: Bram Date: Tue Jan 20 00:13:42 2026 +0100 init diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a147a18 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "third_party/json"] + path = third_party/json + url = https://github.com/nlohmann/json.git +[submodule "third_party/spdlog"] + path = third_party/spdlog + url = https://github.com/gabime/spdlog.git +[submodule "third_party/glslang"] + path = third_party/glslang + url = https://github.com/KhronosGroup/glslang.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..00ea203 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,53 @@ +add_executable(TheChef + main.cpp + "ShaderCompiler.h" "ShaderCompiler.cpp" +) + +target_compile_features(TheChef PRIVATE cxx_std_17) + +# Good practice for tools +target_compile_definitions(TheChef PRIVATE + _CRT_SECURE_NO_WARNINGS +) +if (NOT TARGET nlohmann_json::nlohmann_json) + add_subdirectory(third_party/json) +endif() + +if(NOT TARGET spdlog::spdlog) + add_subdirectory(third_party/spdlog) +endif() + +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +set(GLSLANG_DIR "${CMAKE_CURRENT_SOURCE_DIR}/third_party/glslang") +set(SCRIPT "${GLSLANG_DIR}/update_glslang_sources.py") + +execute_process( + COMMAND "${Python3_EXECUTABLE}" "${SCRIPT}" + WORKING_DIRECTORY "${GLSLANG_DIR}" + RESULT_VARIABLE rc + OUTPUT_VARIABLE out + ERROR_VARIABLE err + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_STRIP_TRAILING_WHITESPACE +) + +if(NOT rc EQUAL 0) + message(FATAL_ERROR + "update_glslang_sources.py failed (exit code ${rc})\n" + "--- stdout ---\n${out}\n" + "--- stderr ---\n${err}\n" + ) +endif() + +add_subdirectory(third_party/glslang) + + +target_link_libraries(TheChef PRIVATE nlohmann_json::nlohmann_json spdlog::spdlog glslang glslang-default-resource-limits) + + # Optional: warnings +if (MSVC) + target_compile_options(TheChef PRIVATE /W4) +else() + target_compile_options(TheChef PRIVATE -Wall -Wextra -Wpedantic) +endif() diff --git a/ShaderCompiler.cpp b/ShaderCompiler.cpp new file mode 100644 index 0000000..40c3064 --- /dev/null +++ b/ShaderCompiler.cpp @@ -0,0 +1 @@ +#include "ShaderCompiler.h" diff --git a/ShaderCompiler.h b/ShaderCompiler.h new file mode 100644 index 0000000..e242120 --- /dev/null +++ b/ShaderCompiler.h @@ -0,0 +1,250 @@ +#ifndef SHADERCOMPILER_H +#define SHADERCOMPILER_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// glslang +#include +#include +#include +#include + +namespace fs = std::filesystem; + +class SpirvShaderCompiler +{ +public: + struct Options + { + std::vector includeDirs; // searched for #include "..." + std::vector defines; // "NAME" or "NAME=VALUE" + bool debugInfo = false; // emit debug info in SPIR-V + glslang::EShTargetClientVersion vulkanTarget = glslang::EShTargetVulkan_1_2; + glslang::EShTargetLanguageVersion spirvTarget = glslang::EShTargetSpv_1_5; + int glslVersion = 450; + }; + + explicit SpirvShaderCompiler(Options opt = {}) + : m_opt(std::move(opt)) + { + ensureInitialized(); + } + + // Returns true if rebuilt. Throws on compilation/dependency errors. + bool CompileFileIfNeeded(const fs::path& entryFile, const fs::path& outSpv) + { + if (!IsEntryShader(entryFile)) + throw std::runtime_error("CompileFileIfNeeded called with non-entry shader: " + entryFile.string()); + + if (!NeedsRebuild(entryFile, outSpv)) + return false; + + const EShLanguage stage = StageFromExtension(entryFile); + + auto spirv = CompileToSpirvWords(entryFile, stage); + WriteSpv(outSpv, spirv); + return true; + } + + // Utility for your cooker rules + static bool IsEntryShader(const fs::path& p) + { + const auto ext = ToLower(p.extension().string()); + return ext == ".vert" || ext == ".frag"; + } + + static bool IsIncludeOnlyShader(const fs::path& p) + { + return ToLower(p.extension().string()) == ".glsl"; + } + +private: + Options m_opt; + + // --- init --- + static void ensureInitialized() + { + static bool initialized = false; + if (!initialized) { + glslang::InitializeProcess(); + initialized = true; + } + } + + // --- file helpers --- + static std::string ReadTextFile(const fs::path& p) + { + std::ifstream f(p, std::ios::in); + if (!f) throw std::runtime_error("Failed to open file: " + p.string()); + std::stringstream ss; + ss << f.rdbuf(); + return ss.str(); + } + + static void EnsureParentDir(const fs::path& p) + { + fs::create_directories(p.parent_path()); + } + + static void WriteSpv(const fs::path& out, const std::vector& spirv) + { + EnsureParentDir(out); + std::ofstream f(out, std::ios::binary); + if (!f) throw std::runtime_error("Failed to write: " + out.string()); + f.write(reinterpret_cast(spirv.data()), + static_cast(spirv.size() * sizeof(uint32_t))); + } + + static std::string ToLower(std::string s) + { + for (char& c : s) c = (char)std::tolower((unsigned char)c); + return s; + } + + static EShLanguage StageFromExtension(const fs::path& p) + { + const auto ext = ToLower(p.extension().string()); + if (ext == ".vert") return EShLangVertex; + if (ext == ".frag") return EShLangFragment; + throw std::runtime_error("Unknown entry shader stage extension: " + p.string()); + } + + static std::vector FindIncludes(const std::string& text) + { + std::vector out; + static const std::regex re( + R"(^\s*#\s*include\s*"([^"]+)\"\s*$)", + std::regex::icase + ); + + std::string line; + std::istringstream iss(text); + while (std::getline(iss, line)) { + // strip single-line comments + auto pos = line.find("//"); + if (pos != std::string::npos) line = line.substr(0, pos); + + std::smatch m; + if (std::regex_search(line, m, re)) { + out.push_back(m[1].str()); + } + } + return out; + } + + fs::path ResolveInclude(const fs::path& includingFile, const std::string& includeName) const + { + { + fs::path p = includingFile.parent_path() / includeName; + if (fs::exists(p)) return fs::weakly_canonical(p); + } + for (const auto& d : m_opt.includeDirs) { + fs::path p = d / includeName; + if (fs::exists(p)) return fs::weakly_canonical(p); + } + + throw std::runtime_error( + "Failed to resolve include \"" + includeName + "\" included from " + includingFile.string() + ); + } + + void CollectDepsRecursive(const fs::path& file, std::unordered_set& visitedAbs) const + { + fs::path abs = fs::weakly_canonical(file); + const std::string key = abs.string(); + if (visitedAbs.contains(key)) return; + visitedAbs.insert(key); + + const std::string text = ReadTextFile(abs); + for (const auto& inc : FindIncludes(text)) { + fs::path resolved = ResolveInclude(abs, inc); + CollectDepsRecursive(resolved, visitedAbs); + } + } + + fs::file_time_type NewestDependencyTime(const fs::path& entryFile) const + { + std::unordered_set deps; + CollectDepsRecursive(entryFile, deps); + + fs::file_time_type newest = fs::file_time_type::min(); + for (const auto& dep : deps) { + auto t = fs::last_write_time(dep); + if (t > newest) newest = t; + } + return newest; + } + + bool NeedsRebuild(const fs::path& entryFile, const fs::path& outSpv) const + { + if (!fs::exists(outSpv)) return true; + + const auto outTime = fs::last_write_time(outSpv); + const auto newestDep = NewestDependencyTime(entryFile); + return newestDep > outTime; + } + + // --- compilation --- + std::vector CompileToSpirvWords(const fs::path& entryFile, EShLanguage stage) const + { + const std::string source = ReadTextFile(entryFile); + + std::string preamble; + for (const auto& d : m_opt.defines) + preamble += "#define " + d + "\n"; + + const char* strings[] = { source.c_str() }; + + glslang::TShader shader(stage); + shader.setStrings(strings, 1); + shader.setPreamble(preamble.c_str()); + shader.setEntryPoint("main"); + shader.setSourceEntryPoint("main"); + + shader.setEnvInput(glslang::EShSourceGlsl, stage, glslang::EShClientVulkan, 100); + shader.setEnvClient(glslang::EShClientVulkan, m_opt.vulkanTarget); + shader.setEnvTarget(glslang::EShTargetSpv, m_opt.spirvTarget); + + DirStackFileIncluder includer; + // Search local first, then include dirs: + includer.pushExternalLocalDirectory(entryFile.parent_path().string()); + for (auto& d : m_opt.includeDirs) + includer.pushExternalLocalDirectory(d.string()); + + const TBuiltInResource* resources = GetDefaultResources(); + const EShMessages messages = (EShMessages)(EShMsgSpvRules | EShMsgVulkanRules); + + if (!shader.parse(resources, m_opt.glslVersion, false, messages, includer)) { + throw std::runtime_error( + "GLSL parse failed: " + entryFile.string() + "\n" + + shader.getInfoLog() + "\n" + shader.getInfoDebugLog() + ); + } + + glslang::TProgram program; + program.addShader(&shader); + + if (!program.link(messages)) { + throw std::runtime_error( + "GLSL link failed: " + entryFile.string() + "\n" + + program.getInfoLog() + "\n" + program.getInfoDebugLog() + ); + } + + std::vector spirv; + glslang::SpvOptions spvOpt{}; + spvOpt.generateDebugInfo = m_opt.debugInfo; + glslang::GlslangToSpv(*program.getIntermediate(stage), spirv, &spvOpt); + return spirv; + } +}; + +#endif //SHADERCOMPILER_H diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..bec67e5 --- /dev/null +++ b/main.cpp @@ -0,0 +1,254 @@ +// main.cpp (updated) + +// Standard +#include +#include +#include +#include +#include +#include +#include +#include + +// Manifest +#include + +// Logging +#include +#include + +// Your shader compiler header +#include "ShaderCompiler.h" + +namespace fs = std::filesystem; +using json = nlohmann::json; + +static void ensure_parent_dir(const fs::path& p) { + fs::create_directories(p.parent_path()); +} + +static std::string to_lower(std::string s) { + for (char& c : s) c = (char)std::tolower((unsigned char)c); + return s; +} + +struct Args { + fs::path input; + fs::path output; + bool clean = false; +}; + +static Args parse_args(int argc, char** argv) { + Args a; + for (int i = 1; i < argc; ++i) { + std::string s = argv[i]; + auto next = [&]() -> std::string { + if (i + 1 >= argc) throw std::runtime_error("Missing value after " + s); + return argv[++i]; + }; + + if (s == "--input") a.input = next(); + else if (s == "--output") a.output = next(); + else if (s == "--clean") a.clean = true; + else if (s == "--help" || s == "-h") { + spdlog::info("AssetCooker --input --output [--clean]"); + std::exit(0); + } else { + throw std::runtime_error("Unknown arg: " + s); + } + } + + if (a.input.empty() || a.output.empty()) + throw std::runtime_error("Usage: AssetCooker --input --output [--clean]"); + + return a; +} + +static std::string classify_type(const fs::path& src) { + const std::string ext = to_lower(src.extension().string()); + if (ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".tga") return "texture"; + // Treat these as "shader family" (entry: .vert/.frag/.comp, include-only: .glsl) + if (ext == ".vert" || ext == ".frag" || ext == ".comp" || ext == ".glsl") return "shader"; + if (ext == ".wav" || ext == ".ogg" || ext == ".mp3") return "audio"; + if (ext == ".gltf" || ext == ".glb" || ext == ".fbx" || ext == ".obj") return "mesh"; + return "raw"; +} + +static bool needs_rebuild(const fs::path& src, const fs::path& dst) { + if (!fs::exists(dst)) return true; + return fs::last_write_time(src) > fs::last_write_time(dst); +} + +static bool copy_if_needed(const fs::path& src, const fs::path& dst) { + if (!needs_rebuild(src, dst)) return false; + ensure_parent_dir(dst); + fs::copy_file(src, dst, fs::copy_options::overwrite_existing); + return true; +} + +// Choose your SPV output naming scheme. +// Option A: shader.vert -> shader.vert.spv (keeps original extension) +// Option B: shader.vert -> shader.spv (replace with .spv) +static fs::path make_spv_output_path(const fs::path& dstLikeSrc) { + fs::path out = dstLikeSrc; + // Option A: + out.replace_extension(dstLikeSrc.extension().string() + ".spv"); + // Option B (uncomment to use): + // out.replace_extension(".spv"); + return out; +} + +static std::int64_t filetime_to_epoch_ns(const fs::file_time_type& ft) { + return (std::int64_t)std::chrono::duration_cast( + ft.time_since_epoch() + ).count(); +} + +int main(int argc, char** argv) { + auto log = spdlog::stdout_color_mt("AssetCooker"); + spdlog::set_default_logger(log); + + spdlog::set_pattern("[%H:%M:%S] [%^%l%$] %v"); + // spdlog::set_level(spdlog::level::debug); + + try { + const Args args = parse_args(argc, argv); + + if (!fs::exists(args.input) || !fs::is_directory(args.input)) { + spdlog::error("Input dir does not exist: {}", args.input.string()); + return 1; + } + + if (args.clean && fs::exists(args.output)) { + spdlog::info("Cleaning output dir: {}", args.output.string()); + fs::remove_all(args.output); + } + fs::create_directories(args.output); + + // ---------------------------- + // Shader compiler setup + // ---------------------------- + SpirvShaderCompiler::Options scOpt; + scOpt.debugInfo = false; // set true if you want debug info in SPIR-V + + // Common conventions (adjust to your folder layout): + // - shaders live under /shaders + // - includes live under /shaders/include + // + // If you don't have these folders, either remove these or point them elsewhere. + scOpt.includeDirs = { + args.input / "shaders", + args.input / "shaders" / "include" + }; + + // Optional defines: + // scOpt.defines = { "MY_DEFINE=1", "USE_FOG" }; + + SpirvShaderCompiler shaderCompiler(scOpt); + + // ---------------------------- + // Manifest init + // ---------------------------- + json manifest; + manifest["version"] = 1; + manifest["assets"] = json::array(); + + std::size_t cooked = 0, skipped = 0; + + for (auto it = fs::recursive_directory_iterator(args.input); + it != fs::recursive_directory_iterator(); + ++it) { + + if (!it->is_regular_file()) continue; + + fs::path src = it->path(); + fs::path rel = fs::relative(src, args.input); + fs::path dst = args.output / rel; + + const std::string type = classify_type(src); + + bool changed = false; + fs::path finalOut = dst; // what actually ends up in output (if any) + std::string finalType = type; // stored in manifest + + if (type == "shader") { + // Include-only shader (.glsl): typically NOT cooked to output. + if (SpirvShaderCompiler::IsIncludeOnlyShader(src)) { + changed = false; + finalType = "shader_include"; + // If you *do* want to copy includes, uncomment: + // changed = copy_if_needed(src, dst); + // finalOut = dst; + } + // Entry shader (.vert/.frag): compile to SPIR-V. + else if (SpirvShaderCompiler::IsEntryShader(src)) { + fs::path spvOut = make_spv_output_path(dst); + changed = shaderCompiler.CompileFileIfNeeded(src, spvOut); + + finalOut = spvOut; + finalType = "shader_spirv"; + } + // Something else classified as shader (ex: .comp in your classifier) + // but not supported by the compiler yet -> copy raw as fallback. + else { + changed = copy_if_needed(src, dst); + finalOut = dst; + finalType = "shader_raw"; + } + } else { + // Normal assets: copy if needed. + changed = copy_if_needed(src, dst); + finalOut = dst; + } + + if (changed) { + cooked++; + spdlog::debug("Cooked: {} -> {}", + rel.generic_string(), + fs::relative(finalOut, args.output).generic_string()); + } else { + skipped++; + spdlog::debug("Skipped (up-to-date): {}", rel.generic_string()); + } + + // ---------------------------- + // Manifest entry + // ---------------------------- + json entry; + entry["src"] = rel.generic_string(); // relative to input + entry["type"] = finalType; + + // If this asset produced an output file, record it. + // If not (e.g. shader includes), set out to null. + if (fs::exists(finalOut) && fs::is_regular_file(finalOut)) { + entry["out"] = fs::relative(finalOut, args.output).generic_string(); + entry["size_bytes"] = (std::uintmax_t)fs::file_size(finalOut); + entry["mtime_epoch_ns"] = filetime_to_epoch_ns(fs::last_write_time(finalOut)); + } else { + entry["out"] = nullptr; + entry["size_bytes"] = (std::uintmax_t)fs::file_size(src); + entry["mtime_epoch_ns"] = filetime_to_epoch_ns(fs::last_write_time(src)); + } + + manifest["assets"].push_back(std::move(entry)); + } + + // Write manifest.json + { + fs::path manifestPath = args.output / "manifest.json"; + std::ofstream out(manifestPath); + if (!out) { + throw std::runtime_error("Failed to write: " + manifestPath.string()); + } + out << manifest.dump(2) << "\n"; + } + + spdlog::info("Cook done. cooked={} skipped={} output={}", + cooked, skipped, args.output.string()); + return 0; + + } catch (const std::exception& e) { + spdlog::critical("Cook failed: {}", e.what()); + return 1; + } +} diff --git a/third_party/glslang b/third_party/glslang new file mode 160000 index 0000000..7881226 --- /dev/null +++ b/third_party/glslang @@ -0,0 +1 @@ +Subproject commit 7881226269b1596feb604515743f528f02041375 diff --git a/third_party/json b/third_party/json new file mode 160000 index 0000000..2bb9d59 --- /dev/null +++ b/third_party/json @@ -0,0 +1 @@ +Subproject commit 2bb9d59fdeeb8784764267e13e2ae3ab6897cb25 diff --git a/third_party/spdlog b/third_party/spdlog new file mode 160000 index 0000000..687226d --- /dev/null +++ b/third_party/spdlog @@ -0,0 +1 @@ +Subproject commit 687226d95d6e7a6f05136efaa4a13f606c42a629