diff --git a/engine/src/Resources/Shader/ShaderLoader.cpp b/engine/src/Resources/Shader/ShaderLoader.cpp index 8d0c3859..2f82c5df 100644 --- a/engine/src/Resources/Shader/ShaderLoader.cpp +++ b/engine/src/Resources/Shader/ShaderLoader.cpp @@ -1,11 +1,695 @@ #include -#include + #include #include +#include + +#include +#include +#include +#include +#include +#include +#include namespace XCEngine { namespace Resources { +namespace { + +std::string ToStdString(const Containers::Array& data) { + return std::string(reinterpret_cast(data.Data()), data.Size()); +} + +Containers::Array TryReadFileData( + const std::filesystem::path& filePath, + bool& opened) { + Containers::Array data; + + std::ifstream file(filePath, std::ios::binary | std::ios::ate); + if (!file.is_open()) { + opened = false; + return data; + } + + opened = true; + const std::streamsize size = file.tellg(); + if (size <= 0) { + return data; + } + + file.seekg(0, std::ios::beg); + data.Resize(static_cast(size)); + if (!file.read(reinterpret_cast(data.Data()), size)) { + data.Clear(); + } + + return data; +} + +Containers::Array ReadShaderFileData(const Containers::String& path) { + bool opened = false; + const std::filesystem::path inputPath(path.CStr()); + Containers::Array data = TryReadFileData(inputPath, opened); + if (opened || path.Empty() || inputPath.is_absolute()) { + return data; + } + + const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (resourceRoot.Empty()) { + return data; + } + + return TryReadFileData(std::filesystem::path(resourceRoot.CStr()) / inputPath, opened); +} + +Containers::String NormalizePathString(const std::filesystem::path& path) { + return Containers::String(path.lexically_normal().generic_string().c_str()); +} + +Containers::String GetPathExtension(const Containers::String& path) { + size_t dotPos = Containers::String::npos; + for (size_t i = path.Length(); i > 0; --i) { + if (path[i - 1] == '.') { + dotPos = i - 1; + break; + } + } + + if (dotPos == Containers::String::npos) { + return Containers::String(); + } + + return path.Substring(dotPos + 1); +} + +size_t SkipWhitespace(const std::string& text, size_t pos) { + while (pos < text.size() && std::isspace(static_cast(text[pos])) != 0) { + ++pos; + } + return pos; +} + +std::string TrimCopy(const std::string& text) { + const size_t first = SkipWhitespace(text, 0); + if (first >= text.size()) { + return std::string(); + } + + size_t last = text.size(); + while (last > first && std::isspace(static_cast(text[last - 1])) != 0) { + --last; + } + + return text.substr(first, last - first); +} + +bool FindValueStart(const std::string& json, const char* key, size_t& valuePos) { + const std::string token = std::string("\"") + key + "\""; + const size_t keyPos = json.find(token); + if (keyPos == std::string::npos) { + return false; + } + + const size_t colonPos = json.find(':', keyPos + token.length()); + if (colonPos == std::string::npos) { + return false; + } + + valuePos = SkipWhitespace(json, colonPos + 1); + return valuePos < json.size(); +} + +bool ParseQuotedString( + const std::string& text, + size_t quotePos, + Containers::String& outValue, + size_t* nextPos = nullptr) { + if (quotePos >= text.size() || text[quotePos] != '"') { + return false; + } + + std::string parsed; + ++quotePos; + + while (quotePos < text.size()) { + const char ch = text[quotePos]; + if (ch == '\\') { + if (quotePos + 1 >= text.size()) { + return false; + } + + parsed.push_back(text[quotePos + 1]); + quotePos += 2; + continue; + } + + if (ch == '"') { + outValue = parsed.c_str(); + if (nextPos != nullptr) { + *nextPos = quotePos + 1; + } + return true; + } + + parsed.push_back(ch); + ++quotePos; + } + + return false; +} + +bool TryParseStringValue(const std::string& json, const char* key, Containers::String& outValue) { + size_t valuePos = 0; + if (!FindValueStart(json, key, valuePos)) { + return false; + } + + return ParseQuotedString(json, valuePos, outValue); +} + +bool TryExtractDelimitedValue( + const std::string& json, + const char* key, + char openChar, + char closeChar, + std::string& outValue) { + size_t valuePos = 0; + if (!FindValueStart(json, key, valuePos) || valuePos >= json.size() || json[valuePos] != openChar) { + return false; + } + + bool inString = false; + bool escaped = false; + int depth = 0; + + for (size_t pos = valuePos; pos < json.size(); ++pos) { + const char ch = json[pos]; + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (ch == openChar) { + ++depth; + } else if (ch == closeChar) { + --depth; + if (depth == 0) { + outValue = json.substr(valuePos, pos - valuePos + 1); + return true; + } + } + } + + return false; +} + +bool TryExtractObject(const std::string& json, const char* key, std::string& outObject) { + return TryExtractDelimitedValue(json, key, '{', '}', outObject); +} + +bool TryExtractArray(const std::string& json, const char* key, std::string& outArray) { + return TryExtractDelimitedValue(json, key, '[', ']', outArray); +} + +bool TryParseStringMapObject( + const std::string& objectText, + const std::function& onEntry) { + if (!onEntry || objectText.empty() || objectText.front() != '{' || objectText.back() != '}') { + return false; + } + + size_t pos = 1; + while (pos < objectText.size()) { + pos = SkipWhitespace(objectText, pos); + if (pos >= objectText.size()) { + return false; + } + + if (objectText[pos] == '}') { + return true; + } + + Containers::String key; + if (!ParseQuotedString(objectText, pos, key, &pos)) { + return false; + } + + pos = SkipWhitespace(objectText, pos); + if (pos >= objectText.size() || objectText[pos] != ':') { + return false; + } + + pos = SkipWhitespace(objectText, pos + 1); + Containers::String value; + if (!ParseQuotedString(objectText, pos, value, &pos)) { + return false; + } + + onEntry(key, value); + + pos = SkipWhitespace(objectText, pos); + if (pos >= objectText.size()) { + return false; + } + + if (objectText[pos] == ',') { + ++pos; + continue; + } + + if (objectText[pos] == '}') { + return true; + } + + return false; + } + + return false; +} + +bool SplitTopLevelArrayElements(const std::string& arrayText, std::vector& outElements) { + outElements.clear(); + if (arrayText.size() < 2 || arrayText.front() != '[' || arrayText.back() != ']') { + return false; + } + + bool inString = false; + bool escaped = false; + int objectDepth = 0; + int arrayDepth = 0; + size_t elementStart = std::string::npos; + + for (size_t pos = 1; pos + 1 < arrayText.size(); ++pos) { + const char ch = arrayText[pos]; + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"') { + if (elementStart == std::string::npos) { + elementStart = pos; + } + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (std::isspace(static_cast(ch)) != 0) { + continue; + } + + if (elementStart == std::string::npos) { + elementStart = pos; + } + + if (ch == '{') { + ++objectDepth; + continue; + } + if (ch == '[') { + ++arrayDepth; + continue; + } + if (ch == '}') { + --objectDepth; + continue; + } + if (ch == ']') { + --arrayDepth; + continue; + } + + if (ch == ',' && objectDepth == 0 && arrayDepth == 0) { + if (elementStart != std::string::npos && pos > elementStart) { + outElements.push_back(TrimCopy(arrayText.substr(elementStart, pos - elementStart))); + } + elementStart = std::string::npos; + } + } + + if (elementStart != std::string::npos) { + const std::string tail = TrimCopy(arrayText.substr(elementStart, arrayText.size() - 1 - elementStart)); + if (!tail.empty()) { + outElements.push_back(tail); + } + } + + return true; +} + +bool TryParseShaderType(const Containers::String& value, ShaderType& outType) { + const Containers::String normalized = value.Trim().ToLower(); + if (normalized == "vertex" || normalized == "vs") { + outType = ShaderType::Vertex; + return true; + } + if (normalized == "fragment" || normalized == "pixel" || normalized == "ps") { + outType = ShaderType::Fragment; + return true; + } + if (normalized == "geometry" || normalized == "gs") { + outType = ShaderType::Geometry; + return true; + } + if (normalized == "compute" || normalized == "cs") { + outType = ShaderType::Compute; + return true; + } + if (normalized == "hull" || normalized == "hs") { + outType = ShaderType::Hull; + return true; + } + if (normalized == "domain" || normalized == "ds") { + outType = ShaderType::Domain; + return true; + } + + return false; +} + +bool TryParseShaderLanguage(const Containers::String& value, ShaderLanguage& outLanguage) { + const Containers::String normalized = value.Trim().ToLower(); + if (normalized == "glsl") { + outLanguage = ShaderLanguage::GLSL; + return true; + } + if (normalized == "hlsl") { + outLanguage = ShaderLanguage::HLSL; + return true; + } + if (normalized == "spirv" || normalized == "spv") { + outLanguage = ShaderLanguage::SPIRV; + return true; + } + + return false; +} + +bool TryParseShaderBackend(const Containers::String& value, ShaderBackend& outBackend) { + const Containers::String normalized = value.Trim().ToLower(); + if (normalized == "generic") { + outBackend = ShaderBackend::Generic; + return true; + } + if (normalized == "d3d12" || normalized == "dx12") { + outBackend = ShaderBackend::D3D12; + return true; + } + if (normalized == "opengl" || normalized == "gl") { + outBackend = ShaderBackend::OpenGL; + return true; + } + if (normalized == "vulkan" || normalized == "vk") { + outBackend = ShaderBackend::Vulkan; + return true; + } + + return false; +} + +Containers::String GetDefaultEntryPoint(ShaderLanguage language, ShaderType stage) { + if (language != ShaderLanguage::HLSL) { + return Containers::String("main"); + } + + switch (stage) { + case ShaderType::Vertex: return "MainVS"; + case ShaderType::Fragment: return "MainPS"; + case ShaderType::Geometry: return "MainGS"; + case ShaderType::Compute: return "MainCS"; + case ShaderType::Hull: return "MainHS"; + case ShaderType::Domain: return "MainDS"; + default: return Containers::String(); + } +} + +Containers::String GetDefaultProfile( + ShaderLanguage language, + ShaderBackend backend, + ShaderType stage) { + if (language == ShaderLanguage::HLSL) { + switch (stage) { + case ShaderType::Vertex: return "vs_5_0"; + case ShaderType::Fragment: return "ps_5_0"; + case ShaderType::Geometry: return "gs_5_0"; + case ShaderType::Compute: return "cs_5_0"; + case ShaderType::Hull: return "hs_5_0"; + case ShaderType::Domain: return "ds_5_0"; + default: return Containers::String(); + } + } + + const bool isVulkan = backend == ShaderBackend::Vulkan; + switch (stage) { + case ShaderType::Vertex: + return isVulkan ? "vs_4_50" : "vs_4_30"; + case ShaderType::Fragment: + return isVulkan ? "fs_4_50" : "fs_4_30"; + case ShaderType::Geometry: + return isVulkan ? "gs_4_50" : "gs_4_30"; + case ShaderType::Compute: + return isVulkan ? "cs_4_50" : "cs_4_30"; + case ShaderType::Hull: + return isVulkan ? "hs_4_50" : "hs_4_30"; + case ShaderType::Domain: + return isVulkan ? "ds_4_50" : "ds_4_30"; + default: + return Containers::String(); + } +} + +Containers::String ResolveShaderDependencyPath( + const Containers::String& dependencyPath, + const Containers::String& sourcePath) { + if (dependencyPath.Empty()) { + return dependencyPath; + } + + const std::filesystem::path dependencyFsPath(dependencyPath.CStr()); + if (dependencyFsPath.is_absolute()) { + return NormalizePathString(dependencyFsPath); + } + + const std::filesystem::path sourceFsPath(sourcePath.CStr()); + if (sourceFsPath.is_absolute()) { + return NormalizePathString(sourceFsPath.parent_path() / dependencyFsPath); + } + + const Containers::String& resourceRoot = ResourceManager::Get().GetResourceRoot(); + if (!resourceRoot.Empty()) { + return NormalizePathString( + std::filesystem::path(resourceRoot.CStr()) / + sourceFsPath.parent_path() / + dependencyFsPath); + } + + return NormalizePathString(sourceFsPath.parent_path() / dependencyFsPath); +} + +bool ReadTextFile(const Containers::String& path, Containers::String& outText) { + const Containers::Array data = ReadShaderFileData(path); + if (data.Empty()) { + return false; + } + + outText = ToStdString(data).c_str(); + return true; +} + +size_t CalculateShaderMemorySize(const Shader& shader) { + size_t memorySize = sizeof(Shader) + shader.GetName().Length() + shader.GetPath().Length(); + for (const ShaderPass& pass : shader.GetPasses()) { + memorySize += pass.name.Length(); + for (const ShaderPassTagEntry& tag : pass.tags) { + memorySize += tag.name.Length(); + memorySize += tag.value.Length(); + } + for (const ShaderStageVariant& variant : pass.variants) { + memorySize += variant.entryPoint.Length(); + memorySize += variant.profile.Length(); + memorySize += variant.sourceCode.Length(); + memorySize += variant.compiledBinary.Size(); + } + } + + return memorySize; +} + +ShaderType DetectShaderTypeFromPath(const Containers::String& path) { + const Containers::String ext = GetPathExtension(path).ToLower(); + if (ext == "vert") return ShaderType::Vertex; + if (ext == "frag") return ShaderType::Fragment; + if (ext == "geom") return ShaderType::Geometry; + if (ext == "comp") return ShaderType::Compute; + + return ShaderType::Fragment; +} + +bool LooksLikeShaderManifest(const std::string& sourceText) { + const size_t firstContentPos = SkipWhitespace(sourceText, 0); + return firstContentPos < sourceText.size() && + sourceText[firstContentPos] == '{' && + sourceText.find("\"passes\"") != std::string::npos; +} + +LoadResult LoadShaderManifest(const Containers::String& path, const std::string& jsonText) { + std::string passesArray; + if (!TryExtractArray(jsonText, "passes", passesArray)) { + return LoadResult("Shader manifest is missing a valid passes array: " + path); + } + + std::vector passObjects; + if (!SplitTopLevelArrayElements(passesArray, passObjects) || passObjects.empty()) { + return LoadResult("Shader manifest does not contain any pass objects: " + path); + } + + auto shader = std::make_unique(); + IResource::ConstructParams params; + params.path = path; + params.guid = ResourceGUID::Generate(path); + + Containers::String manifestName; + if (TryParseStringValue(jsonText, "name", manifestName) && !manifestName.Empty()) { + params.name = manifestName; + } else { + const std::filesystem::path shaderPath(path.CStr()); + const std::string stem = shaderPath.stem().generic_string(); + params.name = stem.empty() ? path : Containers::String(stem.c_str()); + } + + shader->Initialize(params); + + for (const std::string& passObject : passObjects) { + Containers::String passName; + if (!TryParseStringValue(passObject, "name", passName) || passName.Empty()) { + return LoadResult("Shader manifest pass is missing a valid name: " + path); + } + + std::string tagsObject; + if (TryExtractObject(passObject, "tags", tagsObject)) { + if (!TryParseStringMapObject( + tagsObject, + [shaderPtr = shader.get(), &passName](const Containers::String& key, const Containers::String& value) { + shaderPtr->SetPassTag(passName, key, value); + })) { + return LoadResult("Shader manifest pass tags could not be parsed: " + path); + } + } + + std::string variantsArray; + if (!TryExtractArray(passObject, "variants", variantsArray)) { + return LoadResult("Shader manifest pass is missing variants: " + path); + } + + std::vector variantObjects; + if (!SplitTopLevelArrayElements(variantsArray, variantObjects) || variantObjects.empty()) { + return LoadResult("Shader manifest pass does not contain any variants: " + path); + } + + for (const std::string& variantObject : variantObjects) { + ShaderStageVariant variant = {}; + + Containers::String stageName; + if (!TryParseStringValue(variantObject, "stage", stageName) || + !TryParseShaderType(stageName, variant.stage)) { + return LoadResult("Shader manifest variant has an invalid stage: " + path); + } + + Containers::String backendName; + if (!TryParseStringValue(variantObject, "backend", backendName) || + !TryParseShaderBackend(backendName, variant.backend)) { + return LoadResult("Shader manifest variant has an invalid backend: " + path); + } + + Containers::String languageName; + if (!TryParseStringValue(variantObject, "language", languageName) || + !TryParseShaderLanguage(languageName, variant.language)) { + return LoadResult("Shader manifest variant has an invalid language: " + path); + } + + Containers::String sourceCode; + if (TryParseStringValue(variantObject, "sourceCode", sourceCode)) { + variant.sourceCode = sourceCode; + } else { + Containers::String sourcePath; + if (!TryParseStringValue(variantObject, "source", sourcePath) && + !TryParseStringValue(variantObject, "sourcePath", sourcePath)) { + return LoadResult("Shader manifest variant is missing source/sourceCode: " + path); + } + + const Containers::String resolvedSourcePath = ResolveShaderDependencyPath(sourcePath, path); + if (!ReadTextFile(resolvedSourcePath, variant.sourceCode)) { + return LoadResult("Failed to read shader variant source: " + resolvedSourcePath); + } + } + + if (!TryParseStringValue(variantObject, "entryPoint", variant.entryPoint)) { + variant.entryPoint = GetDefaultEntryPoint(variant.language, variant.stage); + } + + if (!TryParseStringValue(variantObject, "profile", variant.profile)) { + variant.profile = GetDefaultProfile(variant.language, variant.backend, variant.stage); + } + + shader->AddPassVariant(passName, variant); + } + } + + shader->m_memorySize = CalculateShaderMemorySize(*shader); + return LoadResult(shader.release()); +} + +LoadResult LoadLegacySingleStageShader(const Containers::String& path, const std::string& sourceText) { + auto shader = std::make_unique(); + shader->m_path = path; + shader->m_name = path; + shader->m_guid = ResourceGUID::Generate(path); + + const Containers::String ext = GetPathExtension(path).ToLower(); + if (ext == "hlsl") { + shader->SetShaderLanguage(ShaderLanguage::HLSL); + } else { + shader->SetShaderLanguage(ShaderLanguage::GLSL); + } + + shader->SetShaderType(DetectShaderTypeFromPath(path)); + shader->SetSourceCode(sourceText.c_str()); + shader->m_isValid = true; + shader->m_memorySize = + sizeof(Shader) + + shader->m_name.Length() + + shader->m_path.Length() + + shader->GetSourceCode().Length(); + + return LoadResult(shader.release()); +} + +} // namespace + ShaderLoader::ShaderLoader() = default; ShaderLoader::~ShaderLoader() = default; @@ -27,48 +711,30 @@ bool ShaderLoader::CanLoad(const Containers::String& path) const { return true; } - Containers::String ext = GetExtension(path); - return ext == "vert" || ext == "frag" || ext == "geom" || + const Containers::String ext = GetExtension(path).ToLower(); + return ext == "vert" || ext == "frag" || ext == "geom" || ext == "comp" || ext == "glsl" || ext == "hlsl" || ext == "shader"; } LoadResult ShaderLoader::Load(const Containers::String& path, const ImportSettings* settings) { + (void)settings; + if (IsBuiltinShaderPath(path)) { return CreateBuiltinShaderResource(path); } - Containers::Array data = ReadFileData(path); + const Containers::Array data = ReadShaderFileData(path); if (data.Empty()) { return LoadResult("Failed to read shader file: " + path); } - - Containers::String source; - source.Reserve(data.Size()); - for (size_t i = 0; i < data.Size(); ++i) { - source += static_cast(data[i]); + + const std::string sourceText = ToStdString(data); + const Containers::String ext = GetPathExtension(path).ToLower(); + if (ext == "shader" && LooksLikeShaderManifest(sourceText)) { + return LoadShaderManifest(path, sourceText); } - - Shader* shader = new Shader(); - shader->m_path = path; - shader->m_name = path; - shader->m_guid = ResourceGUID::Generate(path); - - Containers::String ext = GetExtension(path); - if (ext == "hlsl") { - shader->SetShaderLanguage(ShaderLanguage::HLSL); - } else { - shader->SetShaderLanguage(ShaderLanguage::GLSL); - } - - ShaderType shaderType = DetectShaderType(path, source); - shader->SetShaderType(shaderType); - shader->SetSourceCode(source); - - shader->m_isValid = true; - shader->m_memorySize = sizeof(Shader) + shader->m_name.Length() + - shader->m_path.Length() + source.Length(); - - return LoadResult(shader); + + return LoadLegacySingleStageShader(path, sourceText); } ImportSettings* ShaderLoader::GetDefaultSettings() const { @@ -76,17 +742,13 @@ ImportSettings* ShaderLoader::GetDefaultSettings() const { } ShaderType ShaderLoader::DetectShaderType(const Containers::String& path, const Containers::String& source) { - Containers::String ext = GetExtension(path); - - if (ext == "vert") return ShaderType::Vertex; - if (ext == "frag") return ShaderType::Fragment; - if (ext == "geom") return ShaderType::Geometry; - if (ext == "comp") return ShaderType::Compute; - - return ShaderType::Fragment; + (void)source; + return DetectShaderTypeFromPath(path); } bool ShaderLoader::ParseShaderSource(const Containers::String& source, Shader* shader) { + (void)source; + (void)shader; return true; } diff --git a/tests/Resources/Material/test_material_loader.cpp b/tests/Resources/Material/test_material_loader.cpp index a4a0c006..cdf684e7 100644 --- a/tests/Resources/Material/test_material_loader.cpp +++ b/tests/Resources/Material/test_material_loader.cpp @@ -11,6 +11,7 @@ #include #include #include +#include using namespace XCEngine::Resources; using namespace XCEngine::Containers; @@ -41,6 +42,23 @@ bool PumpAsyncLoadsUntilIdle(ResourceManager& manager, return !manager.IsAsyncLoading(); } +void FlipLastByte(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + ASSERT_TRUE(input.is_open()); + + std::vector bytes( + (std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + ASSERT_FALSE(bytes.empty()); + + bytes.back() ^= 0x01; + + std::ofstream output(path, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(output.is_open()); + output.write(bytes.data(), static_cast(bytes.size())); + ASSERT_TRUE(static_cast(output)); +} + TEST(MaterialLoader, GetResourceType) { MaterialLoader loader; EXPECT_EQ(loader.GetResourceType(), ResourceType::Material); @@ -136,6 +154,80 @@ TEST(MaterialLoader, LoadValidMaterialParsesRenderMetadata) { std::remove(shaderPath.string().c_str()); } +TEST(MaterialLoader, LoadMaterialWithShaderManifestResolvesShaderPass) { + namespace fs = std::filesystem; + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + const fs::path shaderRoot = fs::temp_directory_path() / "xc_material_shader_manifest_test"; + const fs::path shaderDir = shaderRoot / "Shaders"; + const fs::path manifestPath = shaderDir / "lit.shader"; + const fs::path materialPath = shaderRoot / "manifest.material"; + + fs::remove_all(shaderRoot); + fs::create_directories(shaderDir); + + { + std::ofstream vertexFile(shaderDir / "lit.vert.glsl"); + ASSERT_TRUE(vertexFile.is_open()); + vertexFile << "#version 430\n// MATERIAL_MANIFEST_GL_VS\nvoid main() {}\n"; + } + + { + std::ofstream fragmentFile(shaderDir / "lit.frag.glsl"); + ASSERT_TRUE(fragmentFile.is_open()); + fragmentFile << "#version 430\n// MATERIAL_MANIFEST_GL_PS\nvoid main() {}\n"; + } + + { + std::ofstream manifestFile(manifestPath); + ASSERT_TRUE(manifestFile.is_open()); + manifestFile << "{\n"; + manifestFile << " \"name\": \"ManifestLit\",\n"; + manifestFile << " \"passes\": [\n"; + manifestFile << " {\n"; + manifestFile << " \"name\": \"ForwardLit\",\n"; + manifestFile << " \"tags\": { \"LightMode\": \"ForwardBase\" },\n"; + manifestFile << " \"variants\": [\n"; + manifestFile << " { \"stage\": \"Vertex\", \"backend\": \"OpenGL\", \"language\": \"GLSL\", \"source\": \"lit.vert.glsl\" },\n"; + manifestFile << " { \"stage\": \"Fragment\", \"backend\": \"OpenGL\", \"language\": \"GLSL\", \"source\": \"lit.frag.glsl\" }\n"; + manifestFile << " ]\n"; + manifestFile << " }\n"; + manifestFile << " ]\n"; + manifestFile << "}\n"; + } + + { + std::ofstream materialFile(materialPath); + ASSERT_TRUE(materialFile.is_open()); + materialFile << "{\n"; + materialFile << " \"shader\": \"" << manifestPath.generic_string() << "\",\n"; + materialFile << " \"shaderPass\": \"ForwardLit\",\n"; + materialFile << " \"renderQueue\": \"Geometry\"\n"; + materialFile << "}\n"; + } + + MaterialLoader loader; + LoadResult result = loader.Load(materialPath.string().c_str()); + ASSERT_TRUE(result); + ASSERT_NE(result.resource, nullptr); + + Material* material = static_cast(result.resource); + ASSERT_NE(material, nullptr); + ASSERT_NE(material->GetShader(), nullptr); + EXPECT_EQ(material->GetShaderPass(), "ForwardLit"); + ASSERT_NE(material->GetShader()->FindPass("ForwardLit"), nullptr); + const ShaderStageVariant* vertexVariant = + material->GetShader()->FindVariant("ForwardLit", ShaderType::Vertex, ShaderBackend::OpenGL); + ASSERT_NE(vertexVariant, nullptr); + EXPECT_NE(std::string(vertexVariant->sourceCode.CStr()).find("MATERIAL_MANIFEST_GL_VS"), std::string::npos); + + delete material; + manager.Shutdown(); + fs::remove_all(shaderRoot); +} + TEST(MaterialLoader, RejectsUnknownRenderQueueName) { const std::filesystem::path materialPath = std::filesystem::current_path() / "material_loader_invalid_queue.material"; @@ -247,6 +339,105 @@ TEST(MaterialLoader, AssetDatabaseCreatesMaterialArtifact) { fs::remove_all(projectRoot); } +TEST(MaterialLoader, ResourceManagerLoadsProjectMaterialTextureAsLazyDependency) { + namespace fs = std::filesystem; + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + const fs::path projectRoot = fs::temp_directory_path() / "xc_material_asset_texture_test"; + const fs::path assetsDir = projectRoot / "Assets"; + const fs::path materialPath = assetsDir / "textured.material"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + fs::copy_file( + GetMeshFixturePath("checker.bmp"), + assetsDir / "checker.bmp", + fs::copy_options::overwrite_existing); + + { + std::ofstream materialFile(materialPath); + ASSERT_TRUE(materialFile.is_open()); + materialFile << "{\n"; + materialFile << " \"renderQueue\": \"geometry\",\n"; + materialFile << " \"textures\": {\n"; + materialFile << " \"baseColorTexture\": \"checker.bmp\"\n"; + materialFile << " }\n"; + materialFile << "}"; + } + + manager.SetResourceRoot(projectRoot.string().c_str()); + + const auto materialHandle = manager.Load("Assets/textured.material"); + ASSERT_TRUE(materialHandle.IsValid()); + ASSERT_EQ(materialHandle->GetTextureBindingCount(), 1u); + EXPECT_EQ(materialHandle->GetTextureBindingName(0), "baseColorTexture"); + EXPECT_EQ( + fs::path(materialHandle->GetTextureBindingPath(0).CStr()).lexically_normal().generic_string(), + (projectRoot / "Assets" / "checker.bmp").lexically_normal().generic_string()); + + const ResourceHandle initialTexture = materialHandle->GetTexture("baseColorTexture"); + EXPECT_FALSE(initialTexture.IsValid()); + EXPECT_GT(manager.GetAsyncPendingCount(), 0u); + + ASSERT_TRUE(PumpAsyncLoadsUntilIdle(manager)); + const ResourceHandle loadedTexture = materialHandle->GetTexture("baseColorTexture"); + ASSERT_TRUE(loadedTexture.IsValid()); + EXPECT_EQ(loadedTexture->GetWidth(), 2u); + EXPECT_EQ(loadedTexture->GetHeight(), 2u); + + manager.SetResourceRoot(""); + manager.Shutdown(); + fs::remove_all(projectRoot); +} + +TEST(MaterialLoader, AssetDatabaseReimportsMaterialWhenTextureDependencyChanges) { + namespace fs = std::filesystem; + using namespace std::chrono_literals; + + const fs::path projectRoot = fs::temp_directory_path() / "xc_material_dependency_reimport_test"; + const fs::path assetsDir = projectRoot / "Assets"; + const fs::path materialPath = assetsDir / "textured.material"; + + fs::remove_all(projectRoot); + fs::create_directories(assetsDir); + fs::copy_file( + GetMeshFixturePath("checker.bmp"), + assetsDir / "checker.bmp", + fs::copy_options::overwrite_existing); + + { + std::ofstream materialFile(materialPath); + ASSERT_TRUE(materialFile.is_open()); + materialFile << "{\n"; + materialFile << " \"baseColorTexture\": \"checker.bmp\"\n"; + materialFile << "}"; + } + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + AssetDatabase::ResolvedAsset firstResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/textured.material", ResourceType::Material, firstResolve)); + ASSERT_TRUE(firstResolve.artifactReady); + const String firstArtifactPath = firstResolve.artifactMainPath; + database.Shutdown(); + + std::this_thread::sleep_for(50ms); + FlipLastByte(assetsDir / "checker.bmp"); + + database.Initialize(projectRoot.string().c_str()); + AssetDatabase::ResolvedAsset secondResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/textured.material", ResourceType::Material, secondResolve)); + ASSERT_TRUE(secondResolve.artifactReady); + EXPECT_NE(firstArtifactPath, secondResolve.artifactMainPath); + EXPECT_TRUE(fs::exists(secondResolve.artifactMainPath.CStr())); + database.Shutdown(); + + fs::remove_all(projectRoot); +} + TEST(MaterialLoader, LoadMaterialArtifactDefersTexturePayloadUntilRequested) { namespace fs = std::filesystem; diff --git a/tests/Resources/Shader/test_shader_loader.cpp b/tests/Resources/Shader/test_shader_loader.cpp index 2cdc674a..31d7e049 100644 --- a/tests/Resources/Shader/test_shader_loader.cpp +++ b/tests/Resources/Shader/test_shader_loader.cpp @@ -14,6 +14,13 @@ using namespace XCEngine::Containers; namespace { +void WriteTextFile(const std::filesystem::path& path, const std::string& contents) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(output.is_open()); + output << contents; + ASSERT_TRUE(static_cast(output)); +} + TEST(ShaderLoader, GetResourceType) { ShaderLoader loader; EXPECT_EQ(loader.GetResourceType(), ResourceType::Shader); @@ -76,6 +83,163 @@ TEST(ShaderLoader, LoadLegacySingleStageShaderBuildsDefaultPassVariant) { std::remove(shaderPath.string().c_str()); } +TEST(ShaderLoader, LoadShaderManifestBuildsMultiPassBackendVariants) { + namespace fs = std::filesystem; + + const fs::path shaderRoot = fs::temp_directory_path() / "xc_shader_manifest_test"; + const fs::path stageRoot = shaderRoot / "stages"; + const fs::path manifestPath = shaderRoot / "multi_pass.shader"; + + fs::remove_all(shaderRoot); + fs::create_directories(stageRoot); + + WriteTextFile(stageRoot / "forward_lit.vs.hlsl", "float4 MainVS() : SV_POSITION { return 0; } // FORWARD_LIT_D3D12_VS\n"); + WriteTextFile(stageRoot / "forward_lit.ps.hlsl", "float4 MainPS() : SV_TARGET { return 1; } // FORWARD_LIT_D3D12_PS\n"); + WriteTextFile(stageRoot / "forward_lit.vert.glsl", "#version 430\n// FORWARD_LIT_GL_VS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "forward_lit.frag.glsl", "#version 430\n// FORWARD_LIT_GL_PS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "forward_lit.vert.vk.glsl", "#version 450\n// FORWARD_LIT_VK_VS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "forward_lit.frag.vk.glsl", "#version 450\n// FORWARD_LIT_VK_PS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "depth_only.vs.hlsl", "float4 MainVS() : SV_POSITION { return 0; } // DEPTH_ONLY_D3D12_VS\n"); + WriteTextFile(stageRoot / "depth_only.ps.hlsl", "float4 MainPS() : SV_TARGET { return 1; } // DEPTH_ONLY_D3D12_PS\n"); + + { + std::ofstream manifest(manifestPath); + ASSERT_TRUE(manifest.is_open()); + manifest << "{\n"; + manifest << " \"name\": \"TestLitShader\",\n"; + manifest << " \"passes\": [\n"; + manifest << " {\n"; + manifest << " \"name\": \"ForwardLit\",\n"; + manifest << " \"tags\": {\n"; + manifest << " \"LightMode\": \"ForwardBase\",\n"; + manifest << " \"Queue\": \"Geometry\"\n"; + manifest << " },\n"; + manifest << " \"variants\": [\n"; + manifest << " { \"stage\": \"Vertex\", \"backend\": \"D3D12\", \"language\": \"HLSL\", \"source\": \"stages/forward_lit.vs.hlsl\", \"entryPoint\": \"MainVS\", \"profile\": \"vs_5_0\" },\n"; + manifest << " { \"stage\": \"Fragment\", \"backend\": \"D3D12\", \"language\": \"HLSL\", \"source\": \"stages/forward_lit.ps.hlsl\", \"entryPoint\": \"MainPS\", \"profile\": \"ps_5_0\" },\n"; + manifest << " { \"stage\": \"Vertex\", \"backend\": \"OpenGL\", \"language\": \"GLSL\", \"source\": \"stages/forward_lit.vert.glsl\" },\n"; + manifest << " { \"stage\": \"Fragment\", \"backend\": \"OpenGL\", \"language\": \"GLSL\", \"source\": \"stages/forward_lit.frag.glsl\" },\n"; + manifest << " { \"stage\": \"Vertex\", \"backend\": \"Vulkan\", \"language\": \"GLSL\", \"source\": \"stages/forward_lit.vert.vk.glsl\" },\n"; + manifest << " { \"stage\": \"Fragment\", \"backend\": \"Vulkan\", \"language\": \"GLSL\", \"source\": \"stages/forward_lit.frag.vk.glsl\" }\n"; + manifest << " ]\n"; + manifest << " },\n"; + manifest << " {\n"; + manifest << " \"name\": \"DepthOnly\",\n"; + manifest << " \"tags\": {\n"; + manifest << " \"LightMode\": \"DepthOnly\"\n"; + manifest << " },\n"; + manifest << " \"variants\": [\n"; + manifest << " { \"stage\": \"Vertex\", \"backend\": \"D3D12\", \"language\": \"HLSL\", \"source\": \"stages/depth_only.vs.hlsl\" },\n"; + manifest << " { \"stage\": \"Fragment\", \"backend\": \"D3D12\", \"language\": \"HLSL\", \"source\": \"stages/depth_only.ps.hlsl\" }\n"; + manifest << " ]\n"; + manifest << " }\n"; + manifest << " ]\n"; + manifest << "}\n"; + } + + ShaderLoader loader; + LoadResult result = loader.Load(manifestPath.string().c_str()); + ASSERT_TRUE(result); + ASSERT_NE(result.resource, nullptr); + + Shader* shader = static_cast(result.resource); + ASSERT_NE(shader, nullptr); + ASSERT_TRUE(shader->IsValid()); + EXPECT_EQ(shader->GetName(), "TestLitShader"); + ASSERT_EQ(shader->GetPassCount(), 2u); + + const ShaderPass* forwardLitPass = shader->FindPass("ForwardLit"); + ASSERT_NE(forwardLitPass, nullptr); + ASSERT_EQ(forwardLitPass->tags.Size(), 2u); + EXPECT_EQ(forwardLitPass->tags[0].name, "LightMode"); + EXPECT_EQ(forwardLitPass->tags[0].value, "ForwardBase"); + EXPECT_EQ(forwardLitPass->tags[1].name, "Queue"); + EXPECT_EQ(forwardLitPass->tags[1].value, "Geometry"); + + const ShaderStageVariant* d3d12Vertex = shader->FindVariant("ForwardLit", ShaderType::Vertex, ShaderBackend::D3D12); + ASSERT_NE(d3d12Vertex, nullptr); + EXPECT_EQ(d3d12Vertex->entryPoint, "MainVS"); + EXPECT_EQ(d3d12Vertex->profile, "vs_5_0"); + EXPECT_NE(std::string(d3d12Vertex->sourceCode.CStr()).find("FORWARD_LIT_D3D12_VS"), std::string::npos); + + const ShaderStageVariant* openglFragment = shader->FindVariant("ForwardLit", ShaderType::Fragment, ShaderBackend::OpenGL); + ASSERT_NE(openglFragment, nullptr); + EXPECT_EQ(openglFragment->entryPoint, "main"); + EXPECT_EQ(openglFragment->profile, "fs_4_30"); + EXPECT_NE(std::string(openglFragment->sourceCode.CStr()).find("FORWARD_LIT_GL_PS"), std::string::npos); + + const ShaderStageVariant* vulkanFragment = shader->FindVariant("ForwardLit", ShaderType::Fragment, ShaderBackend::Vulkan); + ASSERT_NE(vulkanFragment, nullptr); + EXPECT_EQ(vulkanFragment->profile, "fs_4_50"); + EXPECT_NE(std::string(vulkanFragment->sourceCode.CStr()).find("FORWARD_LIT_VK_PS"), std::string::npos); + + const ShaderPass* depthOnlyPass = shader->FindPass("DepthOnly"); + ASSERT_NE(depthOnlyPass, nullptr); + ASSERT_EQ(depthOnlyPass->tags.Size(), 1u); + EXPECT_EQ(depthOnlyPass->tags[0].value, "DepthOnly"); + EXPECT_NE(shader->FindVariant("DepthOnly", ShaderType::Vertex, ShaderBackend::D3D12), nullptr); + EXPECT_NE(shader->FindVariant("DepthOnly", ShaderType::Fragment, ShaderBackend::D3D12), nullptr); + EXPECT_EQ(shader->FindVariant("DepthOnly", ShaderType::Fragment, ShaderBackend::OpenGL), nullptr); + + delete shader; + fs::remove_all(shaderRoot); +} + +TEST(ShaderLoader, ResourceManagerLoadsShaderManifestRelativeToResourceRoot) { + namespace fs = std::filesystem; + + ResourceManager& manager = ResourceManager::Get(); + manager.Initialize(); + + const fs::path previousPath = fs::current_path(); + const fs::path projectRoot = fs::temp_directory_path() / "xc_shader_manifest_resource_root"; + const fs::path shaderDir = projectRoot / "Assets" / "Shaders"; + const fs::path manifestPath = shaderDir / "simple.shader"; + + fs::remove_all(projectRoot); + fs::create_directories(shaderDir); + + WriteTextFile(shaderDir / "simple.vert.glsl", "#version 430\n// SIMPLE_GL_VS\nvoid main() {}\n"); + WriteTextFile(shaderDir / "simple.frag.glsl", "#version 430\n// SIMPLE_GL_PS\nvoid main() {}\n"); + + { + std::ofstream manifest(manifestPath); + ASSERT_TRUE(manifest.is_open()); + manifest << "{\n"; + manifest << " \"name\": \"SimpleShader\",\n"; + manifest << " \"passes\": [\n"; + manifest << " {\n"; + manifest << " \"name\": \"Unlit\",\n"; + manifest << " \"tags\": { \"LightMode\": \"Unlit\" },\n"; + manifest << " \"variants\": [\n"; + manifest << " { \"stage\": \"Vertex\", \"backend\": \"OpenGL\", \"language\": \"GLSL\", \"source\": \"simple.vert.glsl\" },\n"; + manifest << " { \"stage\": \"Fragment\", \"backend\": \"OpenGL\", \"language\": \"GLSL\", \"source\": \"simple.frag.glsl\" }\n"; + manifest << " ]\n"; + manifest << " }\n"; + manifest << " ]\n"; + manifest << "}\n"; + } + + manager.SetResourceRoot(projectRoot.string().c_str()); + fs::current_path(projectRoot.parent_path()); + + { + const ResourceHandle shaderHandle = manager.Load("Assets/Shaders/simple.shader"); + ASSERT_TRUE(shaderHandle.IsValid()); + EXPECT_EQ(shaderHandle->GetName(), "SimpleShader"); + + const ShaderStageVariant* vertexVariant = + shaderHandle->FindVariant("Unlit", ShaderType::Vertex, ShaderBackend::OpenGL); + ASSERT_NE(vertexVariant, nullptr); + EXPECT_NE(std::string(vertexVariant->sourceCode.CStr()).find("SIMPLE_GL_VS"), std::string::npos); + } + + fs::current_path(previousPath); + manager.SetResourceRoot(""); + manager.Shutdown(); + fs::remove_all(projectRoot); +} + TEST(ShaderLoader, LoadBuiltinForwardLitShaderBuildsBackendVariants) { ShaderLoader loader; LoadResult result = loader.Load(GetBuiltinForwardLitShaderPath());