From 0273340e3b086bf27192ae0f3019a3c2ff68b83d Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Tue, 13 Oct 2026 00:28:34 +0200 Subject: [PATCH] feat: add model / texture cooking --- CMakeLists.txt | 15 ++++++++- ModelCooker.cpp | 79 +++++++++++++++++++++++++++++++++++++++++++++++ ModelCooker.h | 28 +++++++++++++++++ TextureCooker.cpp | 57 ++++++++++++++++++++++++++++++++++ TextureCooker.h | 17 ++++++++++ main.cpp | 74 ++++++++++++++++++++++++++++++++------------ 6 files changed, 249 insertions(+), 21 deletions(-) create mode 100644 ModelCooker.cpp create mode 100644 ModelCooker.h create mode 100644 TextureCooker.cpp create mode 100644 TextureCooker.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c361f65..edf4508 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,7 +3,7 @@ add_executable(TheChef "ShaderCompiler.h" "ShaderCompiler.cpp" ) -target_compile_features(TheChef PRIVATE cxx_std_17) +target_compile_features(TheChef PRIVATE cxx_std_20) # Good practice for tools target_compile_definitions(TheChef PRIVATE @@ -45,6 +45,19 @@ add_subdirectory(third_party/glslang) target_link_libraries(TheChef PRIVATE nlohmann_json::nlohmann_json spdlog::spdlog glslang glslang-default-resource-limits) +# Model cooking needs the engine's asset types, so it's only available when built inside Destrum +if (TARGET destrum::assets) + target_sources(TheChef PRIVATE "ModelCooker.h" "ModelCooker.cpp") + target_link_libraries(TheChef PRIVATE destrum::assets) + target_compile_definitions(TheChef PRIVATE THECHEF_MODEL_COOKING) +endif() + +if (TARGET destrum::assets AND TARGET tinyexr) + target_sources(TheChef PRIVATE "TextureCooker.h" "TextureCooker.cpp") + target_link_libraries(TheChef PRIVATE destrum::assets tinyexr) + target_compile_definitions(TheChef PRIVATE THECHEF_TEXTURE_COOKING) +endif() + # Optional: warnings if (MSVC) target_compile_options(TheChef PRIVATE /W4) diff --git a/ModelCooker.cpp b/ModelCooker.cpp new file mode 100644 index 0000000..a4965f1 --- /dev/null +++ b/ModelCooker.cpp @@ -0,0 +1,79 @@ +#include "ModelCooker.h" + +#include +#include + +#include + +ModelCooker::ModelCooker(Options opt) + : m_opt(std::move(opt)) +{ +} + +bool ModelCooker::CookFileIfNeeded(const fs::path& source, const fs::path& output) const +{ + if (!NeedsRebuild(source, output)) + return false; + + ModelDoc::ModelAsset model = ModelDoc::LoadModel(source.generic_string(), ModelFile::CookOptions); + MakePathsPortable(model, source); + + fs::create_directories(output.parent_path()); + ModelFile::WriteToFile(model, output); + return true; +} + +fs::path ModelCooker::MakeOutputPath(const fs::path& dstLikeSrc) +{ + fs::path out = dstLikeSrc; + out.replace_extension(dstLikeSrc.extension().string() + std::string(ModelFile::Extension)); + return out; +} + +bool ModelCooker::NeedsRebuild(const fs::path& source, const fs::path& output) +{ + if (!fs::exists(output)) + return true; + if (fs::last_write_time(source) > fs::last_write_time(output)) + return true; + return ModelFile::ReadVersionFromFile(output) != ModelFile::Version; +} + +void ModelCooker::MakePathsPortable(ModelDoc::ModelAsset& model, const fs::path& source) const +{ + const fs::path inputRoot = fs::absolute(m_opt.inputRoot).lexically_normal(); + const fs::path modelPath = fs::absolute(source).lexically_normal(); + const fs::path modelDir = modelPath.parent_path(); + const std::string reference = modelPath.lexically_relative(inputRoot).generic_string(); + + model.sourcePath = reference; + model.skeleton.assetReference.path = reference; + + for (ModelDoc::MeshPrimitive& primitive : model.primitives) + { + primitive.mesh.assetReference.path = reference; + if (primitive.mesh.skeleton) + primitive.mesh.skeleton->assetReference.path = reference; + } + + for (ModelDoc::MaterialInfo& material : model.materials) + { + material.assetReference.path = reference; + + for (ModelDoc::TextureInfo& texture : material.textures) + { + if (texture.embedded || texture.path.empty()) + continue; + + const fs::path texturePath = fs::path(texture.path).lexically_normal(); + const fs::path fromRoot = texturePath.lexically_relative(inputRoot); + if (fromRoot.empty() || *fromRoot.begin() == "..") + continue; + + texture.path = texturePath.lexically_relative(modelDir).generic_string(); + } + } + + for (SkeletalAnimation& animation : model.animations) + animation.assetReference.path = reference; +} diff --git a/ModelCooker.h b/ModelCooker.h new file mode 100644 index 0000000..6ff5048 --- /dev/null +++ b/ModelCooker.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include + +namespace fs = std::filesystem; + +class ModelCooker +{ +public: + struct Options + { + fs::path inputRoot; + }; + + explicit ModelCooker(Options opt); + + bool CookFileIfNeeded(const fs::path& source, const fs::path& output) const; + + static fs::path MakeOutputPath(const fs::path& dstLikeSrc); + +private: + Options m_opt; + + static bool NeedsRebuild(const fs::path& source, const fs::path& output); + void MakePathsPortable(ModelDoc::ModelAsset& model, const fs::path& source) const; +}; diff --git a/TextureCooker.cpp b/TextureCooker.cpp new file mode 100644 index 0000000..f6bbcf7 --- /dev/null +++ b/TextureCooker.cpp @@ -0,0 +1,57 @@ +#include "TextureCooker.h" + +#include +#include +#include +#include +#include + +#include + +#include + +bool TextureCooker::CookFileIfNeeded(const fs::path& source, const fs::path& output) const +{ + if (!NeedsRebuild(source, output)) + return false; + + float* rgba = nullptr; + int width = 0; + int height = 0; + const char* error = nullptr; + + if (LoadEXR(&rgba, &width, &height, source.string().c_str(), &error) != TINYEXR_SUCCESS) + { + const std::string message = error ? error : "unknown error"; + FreeEXRErrorMessage(error); + throw std::runtime_error("TinyEXR failed to load '" + source.string() + "': " + message); + } + + const std::unique_ptr pixels(rgba, &std::free); + const std::size_t floatCount = static_cast(width) * static_cast(height) * 4; + + const TextureFile::Texture texture = TextureFile::FromRGBA32Float( + static_cast(width), + static_cast(height), + std::span(pixels.get(), floatCount)); + + fs::create_directories(output.parent_path()); + TextureFile::WriteToFile(texture, output); + return true; +} + +fs::path TextureCooker::MakeOutputPath(const fs::path& dstLikeSrc) +{ + fs::path out = dstLikeSrc; + out.replace_extension(dstLikeSrc.extension().string() + std::string(TextureFile::Extension)); + return out; +} + +bool TextureCooker::NeedsRebuild(const fs::path& source, const fs::path& output) +{ + if (!fs::exists(output)) + return true; + if (fs::last_write_time(source) > fs::last_write_time(output)) + return true; + return TextureFile::ReadVersionFromFile(output) != TextureFile::Version; +} diff --git a/TextureCooker.h b/TextureCooker.h new file mode 100644 index 0000000..ce0e364 --- /dev/null +++ b/TextureCooker.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace fs = std::filesystem; + +class TextureCooker +{ +public: + // Returns true if rebuilt. Throws when the image can't be decoded. + bool CookFileIfNeeded(const fs::path& source, const fs::path& output) const; + + static fs::path MakeOutputPath(const fs::path& dstLikeSrc); + +private: + static bool NeedsRebuild(const fs::path& source, const fs::path& output); +}; diff --git a/main.cpp b/main.cpp index db462a4..51f466d 100644 --- a/main.cpp +++ b/main.cpp @@ -13,6 +13,14 @@ #include "ShaderCompiler.h" +#ifdef THECHEF_MODEL_COOKING +#include "ModelCooker.h" +#endif + +#ifdef THECHEF_TEXTURE_COOKING +#include "TextureCooker.h" +#endif + namespace fs = std::filesystem; using json = nlohmann::json; @@ -60,6 +68,7 @@ static Args parse_args(int argc, char** argv) { 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"; + if (ext == ".exr") return "hdr_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"; @@ -79,15 +88,9 @@ static bool copy_if_needed(const fs::path& src, const fs::path& dst) { 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; } @@ -119,32 +122,29 @@ int main(int argc, char** argv) { } fs::create_directories(args.output); - // ---------------------------- - // Shader compiler setup - // ---------------------------- SpirvShaderCompiler::Options scOpt; scOpt.debugInfo = false; // set true if you want debug info in SPIR-V scOpt.defines = { "DEBUG=1" }; - // 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 - // ---------------------------- +#ifdef THECHEF_MODEL_COOKING + ModelCooker::Options mcOpt; + mcOpt.inputRoot = args.input; + + ModelCooker modelCooker(mcOpt); +#endif + +#ifdef THECHEF_TEXTURE_COOKING + TextureCooker textureCooker; +#endif + json manifest; manifest["version"] = 1; manifest["assets"] = json::array(); @@ -191,6 +191,40 @@ int main(int argc, char** argv) { finalOut = dst; finalType = "shader_raw"; } + } else if (type == "mesh") { + // The source model is still copied for code that imports it directly. + changed = copy_if_needed(src, dst); + finalOut = dst; + +#ifdef THECHEF_MODEL_COOKING + // Models that can't be imported on their own (e.g. animation-only files) keep using the source. + const fs::path modelOut = ModelCooker::MakeOutputPath(dst); + try { + changed = modelCooker.CookFileIfNeeded(src, modelOut) || changed; + + finalOut = modelOut; + finalType = "model"; + } catch (const std::exception& e) { + spdlog::warn("Not cooking {}, using the source model instead: {}", rel.generic_string(), e.what()); + } +#endif + } else if (type == "hdr_texture") { +#ifdef THECHEF_TEXTURE_COOKING + const fs::path textureOut = TextureCooker::MakeOutputPath(dst); + try { + changed = textureCooker.CookFileIfNeeded(src, textureOut); + + finalOut = textureOut; + finalType = "texture_cooked"; + } catch (const std::exception& e) { + spdlog::warn("Not cooking {}, copying the source image instead: {}", rel.generic_string(), e.what()); + changed = copy_if_needed(src, dst); + finalOut = dst; + } +#else + changed = copy_if_needed(src, dst); + finalOut = dst; +#endif } else { // Normal assets: copy if needed. changed = copy_if_needed(src, dst);