From 24245decb56b0ab446939d692380c53c3f06e8b3 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sat, 4 Apr 2026 16:32:08 +0800 Subject: [PATCH] Add Unity-like shader authoring MVP importer --- docs/plan/Shader与Material系统下一阶段计划.md | 42 + engine/src/Resources/Shader/ShaderLoader.cpp | 911 +++++++++++++++++- tests/Resources/Shader/test_shader_loader.cpp | 227 +++++ 3 files changed, 1179 insertions(+), 1 deletion(-) diff --git a/docs/plan/Shader与Material系统下一阶段计划.md b/docs/plan/Shader与Material系统下一阶段计划.md index 1a23041e..5cb098d2 100644 --- a/docs/plan/Shader与Material系统下一阶段计划.md +++ b/docs/plan/Shader与Material系统下一阶段计划.md @@ -443,3 +443,45 @@ Unity-like Shader Authoring (.shader) ## 9. 一句话总结 下一阶段不是继续给 builtin forward 打补丁,而是把 `Shader` 和 `Material` 正式提升为 Unity 风格渲染架构中的稳定中层资产与执行契约。 + +## 10. 快速收口策略(`2026-04-04`) + +目标收窄为只处理 `shader / material` 核心主线,不继续扩散到完整阴影功能、render graph、shader graph 或 editor 外围能力。 + +按下面顺序收口: + +1. 先完成 Unity-like `.shader` authoring MVP importer + - 允许最小子集:`Shader / Properties / SubShader / Tags / Pass / HLSLPROGRAM / #pragma vertex / #pragma fragment` + - importer 输出继续复用当前 runtime shader contract:`properties / passes / resources / backend variants` + - 这一阶段不追求完整 Unity ShaderLab,只做 builtin 与主线材质系统需要的最小闭环 + +2. 再迁移 builtin shader 到新 authoring 入口 + - 优先 `ForwardLit / Unlit / ObjectId / DepthOnly / ShadowCaster` + - 要求 importer 产出的 runtime contract 与当前 renderer 消费路径保持一致 + +3. 然后收紧 material 主路径 + - 以 imported shader schema 为主路径完成 property 类型校验、默认值回退、constant layout 与 resource mapping + - builtin alias / canonical-name fallback 只保留兼容兜底,不再作为主执行路径 + +4. 最后做最小回归集收口 + - `shader_tests` + - `material_tests` + - `rendering_unit_tests` + - 必要的 rendering integration smoke + +当前进展(`2026-04-04`): + +- 已完成:Step 1 `Unity-like .shader authoring MVP importer` + - `ShaderLoader` 新增 Unity-like `.shader` authoring 识别与解析入口,但保留现有 JSON manifest `.shader` 兼容路径不动 + - importer 继续落到现有 runtime shader contract:`properties / passes / resources / backend variants` + - `CollectSourceDependencies` 已覆盖新 authoring 路径,`AssetDatabase` 会继续追踪各 backend stage 文件依赖并参与重导入 +- 已验证:`shader_tests` 中新增 authoring 直载与 artifact/reimport 覆盖 +- 下一步:进入 Step 2,把 builtin shader 逐步迁到新 authoring 入口,并确保 renderer 消费路径与当前 contract 保持一致 + +当前阶段明确不做: + +- 完整阴影贴图消费链 +- render graph +- shader graph +- 完整 Unity ShaderLab 全语法 +- 与 shader/material 主线无关的 editor/ui 扩展 diff --git a/engine/src/Resources/Shader/ShaderLoader.cpp b/engine/src/Resources/Shader/ShaderLoader.cpp index 0aa44f8d..d99bfe22 100644 --- a/engine/src/Resources/Shader/ShaderLoader.cpp +++ b/engine/src/Resources/Shader/ShaderLoader.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -583,6 +584,909 @@ bool ReadTextFile(const Containers::String& path, Containers::String& outText) { return true; } +size_t CalculateShaderMemorySize(const Shader& shader); + +struct AuthoringTagEntry { + Containers::String name; + Containers::String value; +}; + +struct AuthoringBackendVariantEntry { + ShaderBackend backend = ShaderBackend::Generic; + ShaderLanguage language = ShaderLanguage::GLSL; + Containers::String vertexSourcePath; + Containers::String fragmentSourcePath; + Containers::String vertexProfile; + Containers::String fragmentProfile; +}; + +struct AuthoringPassEntry { + Containers::String name; + std::vector tags; + Containers::Array resources; + Containers::String vertexEntryPoint; + Containers::String fragmentEntryPoint; + std::vector backendVariants; +}; + +struct AuthoringSubShaderEntry { + std::vector tags; + std::vector passes; +}; + +struct AuthoringShaderDesc { + Containers::String name; + Containers::Array properties; + std::vector subShaders; +}; + +std::string StripAuthoringLineComment(const std::string& line) { + bool inString = false; + bool escaped = false; + for (size_t index = 0; index + 1 < line.size(); ++index) { + const char ch = line[index]; + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"') { + inString = !inString; + continue; + } + + if (!inString && ch == '/' && line[index + 1] == '/') { + return line.substr(0, index); + } + } + + return line; +} + +bool StartsWithKeyword(const std::string& line, const char* keyword) { + const std::string keywordString(keyword); + if (line.size() < keywordString.size() || + line.compare(0, keywordString.size(), keywordString) != 0) { + return false; + } + + return line.size() == keywordString.size() || + std::isspace(static_cast(line[keywordString.size()])) != 0; +} + +void SplitShaderAuthoringLines( + const std::string& sourceText, + std::vector& outLines) { + outLines.clear(); + + std::istringstream stream(sourceText); + std::string rawLine; + while (std::getline(stream, rawLine)) { + std::string line = TrimCopy(StripAuthoringLineComment(rawLine)); + if (line.empty()) { + continue; + } + + if (!StartsWithKeyword(line, "Tags") && + line.size() > 1 && + line.back() == '{') { + line.pop_back(); + const std::string prefix = TrimCopy(line); + if (!prefix.empty()) { + outLines.push_back(prefix); + } + outLines.push_back("{"); + continue; + } + + if (line.size() > 1 && line.front() == '}') { + outLines.push_back("}"); + line = TrimCopy(line.substr(1)); + if (!line.empty()) { + outLines.push_back(line); + } + continue; + } + + outLines.push_back(line); + } +} + +size_t FindMatchingDelimiter( + const std::string& text, + size_t openPos, + char openChar, + char closeChar) { + if (openPos >= text.size() || text[openPos] != openChar) { + return std::string::npos; + } + + bool inString = false; + bool escaped = false; + int depth = 0; + for (size_t pos = openPos; pos < text.size(); ++pos) { + const char ch = text[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) { + return pos; + } + } + } + + return std::string::npos; +} + +size_t FindFirstTopLevelChar(const std::string& text, char target) { + bool inString = false; + bool escaped = false; + int roundDepth = 0; + int squareDepth = 0; + + for (size_t pos = 0; pos < text.size(); ++pos) { + const char ch = text[pos]; + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (ch == target && roundDepth == 0 && squareDepth == 0) { + return pos; + } + + if (ch == '(') { + ++roundDepth; + continue; + } + if (ch == ')') { + --roundDepth; + continue; + } + if (ch == '[') { + ++squareDepth; + continue; + } + if (ch == ']') { + --squareDepth; + continue; + } + + } + + return std::string::npos; +} + +bool SplitCommaSeparatedAuthoring(const std::string& text, std::vector& outParts) { + outParts.clear(); + + bool inString = false; + bool escaped = false; + int roundDepth = 0; + int squareDepth = 0; + size_t partStart = 0; + + for (size_t pos = 0; pos < text.size(); ++pos) { + const char ch = text[pos]; + if (escaped) { + escaped = false; + continue; + } + + if (ch == '\\') { + escaped = true; + continue; + } + + if (ch == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + if (ch == '(') { + ++roundDepth; + continue; + } + if (ch == ')') { + --roundDepth; + continue; + } + if (ch == '[') { + ++squareDepth; + continue; + } + if (ch == ']') { + --squareDepth; + continue; + } + + if (ch == ',' && roundDepth == 0 && squareDepth == 0) { + outParts.push_back(TrimCopy(text.substr(partStart, pos - partStart))); + partStart = pos + 1; + } + } + + outParts.push_back(TrimCopy(text.substr(partStart))); + return true; +} + +Containers::String UnquoteAuthoringValue(const std::string& text) { + const std::string trimmed = TrimCopy(text); + if (trimmed.size() >= 2 && + trimmed.front() == '"' && + trimmed.back() == '"') { + Containers::String parsed; + if (ParseQuotedString(trimmed, 0, parsed)) { + return parsed; + } + } + + return Containers::String(trimmed.c_str()); +} + +bool TryTokenizeQuotedArguments(const std::string& line, std::vector& outTokens) { + outTokens.clear(); + + std::string current; + bool inString = false; + bool escaped = false; + + for (size_t pos = 0; pos < line.size(); ++pos) { + const char ch = line[pos]; + if (escaped) { + current.push_back(ch); + escaped = false; + continue; + } + + if (ch == '\\' && inString) { + escaped = true; + continue; + } + + if (ch == '"') { + inString = !inString; + continue; + } + + if (!inString && std::isspace(static_cast(ch)) != 0) { + if (!current.empty()) { + outTokens.push_back(current); + current.clear(); + } + continue; + } + + current.push_back(ch); + } + + if (inString) { + return false; + } + + if (!current.empty()) { + outTokens.push_back(current); + } + + return !outTokens.empty(); +} + +bool TryParseInlineTagAssignments( + const std::string& line, + std::vector& outTags) { + const size_t openBrace = line.find('{'); + const size_t closeBrace = line.rfind('}'); + if (openBrace == std::string::npos || + closeBrace == std::string::npos || + closeBrace <= openBrace) { + return false; + } + + const std::string content = line.substr(openBrace + 1, closeBrace - openBrace - 1); + size_t pos = 0; + while (pos < content.size()) { + pos = SkipWhitespace(content, pos); + while (pos < content.size() && content[pos] == ',') { + ++pos; + pos = SkipWhitespace(content, pos); + } + if (pos >= content.size()) { + break; + } + + Containers::String key; + if (!ParseQuotedString(content, pos, key, &pos)) { + return false; + } + + pos = SkipWhitespace(content, pos); + if (pos >= content.size() || content[pos] != '=') { + return false; + } + + pos = SkipWhitespace(content, pos + 1); + Containers::String value; + if (!ParseQuotedString(content, pos, value, &pos)) { + return false; + } + + outTags.push_back({ key, value }); + } + + return true; +} + +bool TryParseSemanticAttributes( + const std::string& attributesText, + Containers::String& outSemantic) { + outSemantic.Clear(); + + size_t pos = 0; + while (pos < attributesText.size()) { + pos = attributesText.find('[', pos); + if (pos == std::string::npos) { + break; + } + + const size_t closePos = FindMatchingDelimiter(attributesText, pos, '[', ']'); + if (closePos == std::string::npos) { + return false; + } + + const std::string attributeBody = TrimCopy(attributesText.substr(pos + 1, closePos - pos - 1)); + if (attributeBody.size() > 10 && + attributeBody.compare(0, 9, "Semantic(") == 0 && + attributeBody.back() == ')') { + outSemantic = UnquoteAuthoringValue(attributeBody.substr(9, attributeBody.size() - 10)); + } + + pos = closePos + 1; + } + + return true; +} + +bool TryParseAuthoringPropertyLine( + const std::string& line, + ShaderPropertyDesc& outProperty) { + outProperty = {}; + + const size_t openParen = line.find('('); + if (openParen == std::string::npos) { + return false; + } + + const size_t closeParen = FindMatchingDelimiter(line, openParen, '(', ')'); + if (closeParen == std::string::npos) { + return false; + } + + outProperty.name = Containers::String(TrimCopy(line.substr(0, openParen)).c_str()); + if (outProperty.name.Empty()) { + return false; + } + + std::vector headerParts; + if (!SplitCommaSeparatedAuthoring(line.substr(openParen + 1, closeParen - openParen - 1), headerParts) || + headerParts.size() < 2u) { + return false; + } + + outProperty.displayName = UnquoteAuthoringValue(headerParts[0]); + std::string propertyTypeName = headerParts[1]; + for (size_t index = 2; index < headerParts.size(); ++index) { + propertyTypeName += ","; + propertyTypeName += headerParts[index]; + } + + propertyTypeName = TrimCopy(propertyTypeName); + const size_t rangePos = propertyTypeName.find('('); + if (rangePos != std::string::npos && + TrimCopy(propertyTypeName.substr(0, rangePos)) == "Range") { + propertyTypeName = "Range"; + } + + if (!TryParseShaderPropertyType(propertyTypeName.c_str(), outProperty.type)) { + return false; + } + + const size_t equalsPos = line.find('=', closeParen + 1); + if (equalsPos == std::string::npos) { + return false; + } + + const std::string tail = TrimCopy(line.substr(equalsPos + 1)); + const size_t attributePos = FindFirstTopLevelChar(tail, '['); + const std::string defaultValueText = + attributePos == std::string::npos ? tail : TrimCopy(tail.substr(0, attributePos)); + if (defaultValueText.empty()) { + return false; + } + + outProperty.defaultValue = UnquoteAuthoringValue(defaultValueText); + + if (attributePos != std::string::npos && + !TryParseSemanticAttributes(tail.substr(attributePos), outProperty.semantic)) { + return false; + } + + return true; +} + +bool TryParseAuthoringResourceLine( + const std::string& line, + ShaderResourceBindingDesc& outBinding) { + outBinding = {}; + + const size_t openParen = line.find('('); + if (openParen == std::string::npos) { + return false; + } + + const size_t closeParen = FindMatchingDelimiter(line, openParen, '(', ')'); + if (closeParen == std::string::npos) { + return false; + } + + outBinding.name = Containers::String(TrimCopy(line.substr(0, openParen)).c_str()); + if (outBinding.name.Empty()) { + return false; + } + + std::vector parts; + if (!SplitCommaSeparatedAuthoring(line.substr(openParen + 1, closeParen - openParen - 1), parts) || + parts.size() != 3u) { + return false; + } + + if (!TryParseShaderResourceType(parts[0].c_str(), outBinding.type)) { + return false; + } + + try { + outBinding.set = static_cast(std::stoul(parts[1])); + outBinding.binding = static_cast(std::stoul(parts[2])); + } catch (...) { + return false; + } + + const size_t attributePos = FindFirstTopLevelChar(line.substr(closeParen + 1), '['); + if (attributePos != std::string::npos) { + const std::string attributesText = line.substr(closeParen + 1 + attributePos); + if (!TryParseSemanticAttributes(attributesText, outBinding.semantic)) { + return false; + } + } + + return true; +} + +bool ParseUnityLikeShaderAuthoring( + const Containers::String& path, + const std::string& sourceText, + AuthoringShaderDesc& outDesc, + Containers::String* outError) { + (void)path; + outDesc = {}; + + enum class BlockKind { + None, + Shader, + Properties, + SubShader, + Pass, + Resources + }; + + auto fail = [&outError](const std::string& message, size_t lineNumber) -> bool { + if (outError != nullptr) { + *outError = Containers::String( + ("Unity-like shader parse error at line " + std::to_string(lineNumber) + ": " + message).c_str()); + } + return false; + }; + + std::vector lines; + SplitShaderAuthoringLines(sourceText, lines); + if (lines.empty()) { + return fail("shader file is empty", 0); + } + + std::vector blockStack; + BlockKind pendingBlock = BlockKind::None; + AuthoringSubShaderEntry* currentSubShader = nullptr; + AuthoringPassEntry* currentPass = nullptr; + bool inProgram = false; + + auto currentBlock = [&blockStack]() -> BlockKind { + return blockStack.empty() ? BlockKind::None : blockStack.back(); + }; + + for (size_t lineIndex = 0; lineIndex < lines.size(); ++lineIndex) { + const std::string& line = lines[lineIndex]; + const size_t humanLine = lineIndex + 1; + + if (inProgram) { + if (line == "ENDHLSL" || line == "ENDCG") { + inProgram = false; + continue; + } + + std::vector pragmaTokens; + if (!TryTokenizeQuotedArguments(line, pragmaTokens) || pragmaTokens.empty()) { + continue; + } + + if (pragmaTokens[0] != "#pragma") { + continue; + } + + if (pragmaTokens.size() >= 3u && pragmaTokens[1] == "vertex") { + currentPass->vertexEntryPoint = pragmaTokens[2].c_str(); + continue; + } + if (pragmaTokens.size() >= 3u && pragmaTokens[1] == "fragment") { + currentPass->fragmentEntryPoint = pragmaTokens[2].c_str(); + continue; + } + if (pragmaTokens.size() >= 6u && pragmaTokens[1] == "backend") { + AuthoringBackendVariantEntry backendVariant = {}; + if (!TryParseShaderBackend(pragmaTokens[2].c_str(), backendVariant.backend)) { + return fail("invalid backend pragma backend name", humanLine); + } + if (!TryParseShaderLanguage(pragmaTokens[3].c_str(), backendVariant.language)) { + return fail("invalid backend pragma language name", humanLine); + } + + backendVariant.vertexSourcePath = pragmaTokens[4].c_str(); + backendVariant.fragmentSourcePath = pragmaTokens[5].c_str(); + if (pragmaTokens.size() >= 7u) { + backendVariant.vertexProfile = pragmaTokens[6].c_str(); + } + if (pragmaTokens.size() >= 8u) { + backendVariant.fragmentProfile = pragmaTokens[7].c_str(); + } + + currentPass->backendVariants.push_back(std::move(backendVariant)); + } + continue; + } + + if (line == "{") { + switch (pendingBlock) { + case BlockKind::Shader: + blockStack.push_back(BlockKind::Shader); + break; + case BlockKind::Properties: + blockStack.push_back(BlockKind::Properties); + break; + case BlockKind::SubShader: + outDesc.subShaders.emplace_back(); + currentSubShader = &outDesc.subShaders.back(); + blockStack.push_back(BlockKind::SubShader); + break; + case BlockKind::Pass: + if (currentSubShader == nullptr) { + return fail("pass block must be inside a SubShader", humanLine); + } + currentSubShader->passes.emplace_back(); + currentPass = ¤tSubShader->passes.back(); + blockStack.push_back(BlockKind::Pass); + break; + case BlockKind::Resources: + if (currentPass == nullptr) { + return fail("resources block must be inside a Pass", humanLine); + } + blockStack.push_back(BlockKind::Resources); + break; + case BlockKind::None: + default: + return fail("unexpected opening brace", humanLine); + } + + pendingBlock = BlockKind::None; + continue; + } + + if (line == "}") { + if (blockStack.empty()) { + return fail("unexpected closing brace", humanLine); + } + + const BlockKind closingBlock = blockStack.back(); + blockStack.pop_back(); + if (closingBlock == BlockKind::Pass) { + currentPass = nullptr; + } else if (closingBlock == BlockKind::SubShader) { + currentSubShader = nullptr; + } + continue; + } + + if (StartsWithKeyword(line, "Shader")) { + std::vector tokens; + if (!TryTokenizeQuotedArguments(line, tokens) || tokens.size() < 2u) { + return fail("Shader declaration is missing a name", humanLine); + } + outDesc.name = tokens[1].c_str(); + pendingBlock = BlockKind::Shader; + continue; + } + + if (line == "Properties") { + pendingBlock = BlockKind::Properties; + continue; + } + + if (StartsWithKeyword(line, "SubShader")) { + pendingBlock = BlockKind::SubShader; + continue; + } + + if (StartsWithKeyword(line, "Pass")) { + pendingBlock = BlockKind::Pass; + continue; + } + + if (line == "Resources") { + pendingBlock = BlockKind::Resources; + continue; + } + + if (StartsWithKeyword(line, "Tags")) { + std::vector parsedTags; + if (!TryParseInlineTagAssignments(line, parsedTags)) { + return fail("Tags block must use inline key/value pairs", humanLine); + } + + if (currentPass != nullptr) { + currentPass->tags.insert(currentPass->tags.end(), parsedTags.begin(), parsedTags.end()); + } else if (currentSubShader != nullptr) { + currentSubShader->tags.insert(currentSubShader->tags.end(), parsedTags.begin(), parsedTags.end()); + } else { + return fail("Tags block is only supported inside SubShader or Pass", humanLine); + } + continue; + } + + if (currentBlock() == BlockKind::Properties) { + ShaderPropertyDesc property = {}; + if (!TryParseAuthoringPropertyLine(line, property)) { + return fail("invalid Properties entry", humanLine); + } + outDesc.properties.PushBack(property); + continue; + } + + if (currentBlock() == BlockKind::Resources) { + ShaderResourceBindingDesc resourceBinding = {}; + if (!TryParseAuthoringResourceLine(line, resourceBinding)) { + return fail("invalid Resources entry", humanLine); + } + currentPass->resources.PushBack(resourceBinding); + continue; + } + + if (currentBlock() == BlockKind::Pass && currentPass != nullptr) { + if (StartsWithKeyword(line, "Name")) { + std::vector tokens; + if (!TryTokenizeQuotedArguments(line, tokens) || tokens.size() < 2u) { + return fail("pass Name directive is missing a value", humanLine); + } + currentPass->name = tokens[1].c_str(); + continue; + } + + if (line == "HLSLPROGRAM" || line == "CGPROGRAM") { + inProgram = true; + continue; + } + } + + return fail("unsupported authoring statement: " + line, humanLine); + } + + if (inProgram) { + return fail("program block was not closed", lines.size()); + } + if (!blockStack.empty()) { + return fail("one or more blocks were not closed", lines.size()); + } + if (outDesc.name.Empty()) { + return fail("shader name is missing", 0); + } + if (outDesc.subShaders.empty()) { + return fail("shader does not declare any SubShader blocks", 0); + } + + for (const AuthoringSubShaderEntry& subShader : outDesc.subShaders) { + if (subShader.passes.empty()) { + continue; + } + + for (const AuthoringPassEntry& pass : subShader.passes) { + if (pass.name.Empty()) { + return fail("a Pass is missing a Name directive", 0); + } + if (pass.backendVariants.empty()) { + return fail("a Pass is missing backend variants", 0); + } + } + } + + return true; +} + +LoadResult BuildShaderFromAuthoringDesc( + const Containers::String& path, + const AuthoringShaderDesc& authoringDesc) { + auto shader = std::make_unique(); + IResource::ConstructParams params; + params.path = path; + params.guid = ResourceGUID::Generate(path); + params.name = authoringDesc.name; + shader->Initialize(params); + + for (const ShaderPropertyDesc& property : authoringDesc.properties) { + shader->AddProperty(property); + } + + for (const AuthoringSubShaderEntry& subShader : authoringDesc.subShaders) { + for (const AuthoringPassEntry& pass : subShader.passes) { + ShaderPass shaderPass = {}; + shaderPass.name = pass.name; + shader->AddPass(shaderPass); + + for (const AuthoringTagEntry& subShaderTag : subShader.tags) { + shader->SetPassTag(pass.name, subShaderTag.name, subShaderTag.value); + } + for (const AuthoringTagEntry& passTag : pass.tags) { + shader->SetPassTag(pass.name, passTag.name, passTag.value); + } + + for (const ShaderResourceBindingDesc& resourceBinding : pass.resources) { + shader->AddPassResourceBinding(pass.name, resourceBinding); + } + + for (const AuthoringBackendVariantEntry& backendVariant : pass.backendVariants) { + ShaderStageVariant vertexVariant = {}; + vertexVariant.stage = ShaderType::Vertex; + vertexVariant.backend = backendVariant.backend; + vertexVariant.language = backendVariant.language; + vertexVariant.entryPoint = !pass.vertexEntryPoint.Empty() + ? pass.vertexEntryPoint + : GetDefaultEntryPoint(backendVariant.language, ShaderType::Vertex); + vertexVariant.profile = !backendVariant.vertexProfile.Empty() + ? backendVariant.vertexProfile + : GetDefaultProfile(backendVariant.language, backendVariant.backend, ShaderType::Vertex); + + const Containers::String resolvedVertexPath = + ResolveShaderDependencyPath(backendVariant.vertexSourcePath, path); + if (!ReadTextFile(resolvedVertexPath, vertexVariant.sourceCode)) { + return LoadResult("Failed to read shader authoring vertex source: " + resolvedVertexPath); + } + shader->AddPassVariant(pass.name, vertexVariant); + + ShaderStageVariant fragmentVariant = {}; + fragmentVariant.stage = ShaderType::Fragment; + fragmentVariant.backend = backendVariant.backend; + fragmentVariant.language = backendVariant.language; + fragmentVariant.entryPoint = !pass.fragmentEntryPoint.Empty() + ? pass.fragmentEntryPoint + : GetDefaultEntryPoint(backendVariant.language, ShaderType::Fragment); + fragmentVariant.profile = !backendVariant.fragmentProfile.Empty() + ? backendVariant.fragmentProfile + : GetDefaultProfile(backendVariant.language, backendVariant.backend, ShaderType::Fragment); + + const Containers::String resolvedFragmentPath = + ResolveShaderDependencyPath(backendVariant.fragmentSourcePath, path); + if (!ReadTextFile(resolvedFragmentPath, fragmentVariant.sourceCode)) { + return LoadResult("Failed to read shader authoring fragment source: " + resolvedFragmentPath); + } + shader->AddPassVariant(pass.name, fragmentVariant); + } + } + } + + shader->m_memorySize = CalculateShaderMemorySize(*shader); + return LoadResult(shader.release()); +} + +bool LooksLikeUnityLikeShaderAuthoring(const std::string& sourceText) { + std::vector lines; + SplitShaderAuthoringLines(sourceText, lines); + return !lines.empty() && StartsWithKeyword(lines.front(), "Shader"); +} + +bool CollectUnityLikeShaderDependencyPaths( + const Containers::String& path, + const std::string& sourceText, + Containers::Array& outDependencies) { + outDependencies.Clear(); + + AuthoringShaderDesc authoringDesc = {}; + Containers::String parseError; + if (!ParseUnityLikeShaderAuthoring(path, sourceText, authoringDesc, &parseError)) { + return false; + } + + std::unordered_set seenPaths; + for (const AuthoringSubShaderEntry& subShader : authoringDesc.subShaders) { + for (const AuthoringPassEntry& pass : subShader.passes) { + for (const AuthoringBackendVariantEntry& backendVariant : pass.backendVariants) { + const Containers::String resolvedVertexPath = + ResolveShaderDependencyPath(backendVariant.vertexSourcePath, path); + const std::string vertexKey = ToStdString(resolvedVertexPath); + if (!vertexKey.empty() && seenPaths.insert(vertexKey).second) { + outDependencies.PushBack(resolvedVertexPath); + } + + const Containers::String resolvedFragmentPath = + ResolveShaderDependencyPath(backendVariant.fragmentSourcePath, path); + const std::string fragmentKey = ToStdString(resolvedFragmentPath); + if (!fragmentKey.empty() && seenPaths.insert(fragmentKey).second) { + outDependencies.PushBack(resolvedFragmentPath); + } + } + } + } + + return true; +} + +LoadResult LoadUnityLikeShaderAuthoring(const Containers::String& path, const std::string& sourceText) { + AuthoringShaderDesc authoringDesc = {}; + Containers::String parseError; + if (!ParseUnityLikeShaderAuthoring(path, sourceText, authoringDesc, &parseError)) { + return LoadResult(parseError); + } + + return BuildShaderFromAuthoringDesc(path, authoringDesc); +} + template bool ReadShaderArtifactValue(const Containers::Array& data, size_t& offset, T& outValue) { if (offset + sizeof(T) > data.Size()) { @@ -1115,6 +2019,9 @@ LoadResult ShaderLoader::Load(const Containers::String& path, const ImportSettin if (ext == "shader" && LooksLikeShaderManifest(sourceText)) { return LoadShaderManifest(path, sourceText); } + if (ext == "shader" && LooksLikeUnityLikeShaderAuthoring(sourceText)) { + return LoadUnityLikeShaderAuthoring(path, sourceText); + } return LoadLegacySingleStageShader(path, sourceText); } @@ -1143,7 +2050,9 @@ bool ShaderLoader::CollectSourceDependencies(const Containers::String& path, const std::string sourceText = ToStdString(data); if (!LooksLikeShaderManifest(sourceText)) { - return true; + return LooksLikeUnityLikeShaderAuthoring(sourceText) + ? CollectUnityLikeShaderDependencyPaths(path, sourceText, outDependencies) + : true; } return CollectShaderManifestDependencyPaths(path, sourceText, outDependencies); diff --git a/tests/Resources/Shader/test_shader_loader.cpp b/tests/Resources/Shader/test_shader_loader.cpp index dfe23c38..0b8ab2de 100644 --- a/tests/Resources/Shader/test_shader_loader.cpp +++ b/tests/Resources/Shader/test_shader_loader.cpp @@ -233,6 +233,146 @@ TEST(ShaderLoader, LoadShaderManifestBuildsMultiPassBackendVariants) { fs::remove_all(shaderRoot); } +TEST(ShaderLoader, LoadUnityLikeShaderAuthoringBuildsRuntimeContract) { + namespace fs = std::filesystem; + + const fs::path shaderRoot = fs::temp_directory_path() / "xc_shader_authoring_test"; + const fs::path stageRoot = shaderRoot / "stages"; + const fs::path shaderPath = shaderRoot / "multi_pass.shader"; + + fs::remove_all(shaderRoot); + fs::create_directories(stageRoot); + + WriteTextFile(stageRoot / "forward_lit.vs.hlsl", "float4 MainVS() : SV_POSITION { return 0; } // AUTHORING_FORWARD_LIT_D3D12_VS\n"); + WriteTextFile(stageRoot / "forward_lit.ps.hlsl", "float4 MainPS() : SV_TARGET { return 1; } // AUTHORING_FORWARD_LIT_D3D12_PS\n"); + WriteTextFile(stageRoot / "forward_lit.vert.glsl", "#version 430\n// AUTHORING_FORWARD_LIT_GL_VS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "forward_lit.frag.glsl", "#version 430\n// AUTHORING_FORWARD_LIT_GL_PS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "forward_lit.vert.vk.glsl", "#version 450\n// AUTHORING_FORWARD_LIT_VK_VS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "forward_lit.frag.vk.glsl", "#version 450\n// AUTHORING_FORWARD_LIT_VK_PS\nvoid main() {}\n"); + WriteTextFile(stageRoot / "depth_only.vs.hlsl", "float4 MainVS() : SV_POSITION { return 0; } // AUTHORING_DEPTH_ONLY_D3D12_VS\n"); + WriteTextFile(stageRoot / "depth_only.ps.hlsl", "float4 MainPS() : SV_TARGET { return 1; } // AUTHORING_DEPTH_ONLY_D3D12_PS\n"); + + WriteTextFile( + shaderPath, + R"(Shader "AuthoringLit" +{ + Properties + { + _BaseColor ("Base Color", Color) = (1,1,1,1) [Semantic(BaseColor)] + _MainTex ("Base Map", 2D) = "white" [Semantic(BaseColorTexture)] + } + SubShader + { + Tags { "Queue" = "Geometry" } + Pass + { + Name "ForwardLit" + Tags { "LightMode" = "ForwardBase" } + Resources + { + PerObjectConstants (ConstantBuffer, 1, 0) [Semantic(PerObject)] + MaterialConstants (ConstantBuffer, 2, 0) [Semantic(Material)] + BaseColorTexture (Texture2D, 3, 0) [Semantic(BaseColorTexture)] + LinearClampSampler (Sampler, 4, 0) [Semantic(LinearClampSampler)] + } + HLSLPROGRAM + #pragma vertex MainVS + #pragma fragment MainPS + #pragma backend D3D12 HLSL "stages/forward_lit.vs.hlsl" "stages/forward_lit.ps.hlsl" vs_5_0 ps_5_0 + #pragma backend OpenGL GLSL "stages/forward_lit.vert.glsl" "stages/forward_lit.frag.glsl" + #pragma backend Vulkan GLSL "stages/forward_lit.vert.vk.glsl" "stages/forward_lit.frag.vk.glsl" + ENDHLSL + } + Pass + { + Name "DepthOnly" + Tags { "LightMode" = "DepthOnly" } + HLSLPROGRAM + #pragma vertex MainVS + #pragma fragment MainPS + #pragma backend D3D12 HLSL "stages/depth_only.vs.hlsl" "stages/depth_only.ps.hlsl" + ENDHLSL + } + } +} +)"); + + ShaderLoader loader; + LoadResult result = loader.Load(shaderPath.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(), "AuthoringLit"); + ASSERT_EQ(shader->GetProperties().Size(), 2u); + ASSERT_EQ(shader->GetPassCount(), 2u); + + const ShaderPropertyDesc* baseColorProperty = shader->FindProperty("_BaseColor"); + ASSERT_NE(baseColorProperty, nullptr); + EXPECT_EQ(baseColorProperty->displayName, "Base Color"); + EXPECT_EQ(baseColorProperty->type, ShaderPropertyType::Color); + EXPECT_EQ(baseColorProperty->defaultValue, "(1,1,1,1)"); + EXPECT_EQ(baseColorProperty->semantic, "BaseColor"); + + const ShaderPropertyDesc* baseMapProperty = shader->FindProperty("_MainTex"); + ASSERT_NE(baseMapProperty, nullptr); + EXPECT_EQ(baseMapProperty->type, ShaderPropertyType::Texture2D); + EXPECT_EQ(baseMapProperty->defaultValue, "white"); + EXPECT_EQ(baseMapProperty->semantic, "BaseColorTexture"); + + const ShaderPass* forwardLitPass = shader->FindPass("ForwardLit"); + ASSERT_NE(forwardLitPass, nullptr); + ASSERT_EQ(forwardLitPass->tags.Size(), 2u); + ASSERT_EQ(forwardLitPass->resources.Size(), 4u); + EXPECT_EQ(forwardLitPass->tags[0].name, "Queue"); + EXPECT_EQ(forwardLitPass->tags[0].value, "Geometry"); + EXPECT_EQ(forwardLitPass->tags[1].name, "LightMode"); + EXPECT_EQ(forwardLitPass->tags[1].value, "ForwardBase"); + + const ShaderResourceBindingDesc* baseTextureBinding = + shader->FindPassResourceBinding("ForwardLit", "BaseColorTexture"); + ASSERT_NE(baseTextureBinding, nullptr); + EXPECT_EQ(baseTextureBinding->type, ShaderResourceType::Texture2D); + EXPECT_EQ(baseTextureBinding->set, 3u); + EXPECT_EQ(baseTextureBinding->binding, 0u); + EXPECT_EQ(baseTextureBinding->semantic, "BaseColorTexture"); + + 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("AUTHORING_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, "MainPS"); + EXPECT_EQ(openglFragment->profile, "fs_4_30"); + EXPECT_NE(std::string(openglFragment->sourceCode.CStr()).find("AUTHORING_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("AUTHORING_FORWARD_LIT_VK_PS"), std::string::npos); + + const ShaderPass* depthOnlyPass = shader->FindPass("DepthOnly"); + ASSERT_NE(depthOnlyPass, nullptr); + ASSERT_EQ(depthOnlyPass->tags.Size(), 2u); + EXPECT_EQ(depthOnlyPass->tags[0].name, "Queue"); + EXPECT_EQ(depthOnlyPass->tags[0].value, "Geometry"); + EXPECT_EQ(depthOnlyPass->tags[1].name, "LightMode"); + EXPECT_EQ(depthOnlyPass->tags[1].value, "DepthOnly"); + EXPECT_NE(shader->FindVariant("DepthOnly", ShaderType::Vertex, ShaderBackend::D3D12), nullptr); + EXPECT_NE(shader->FindVariant("DepthOnly", ShaderType::Fragment, ShaderBackend::D3D12), nullptr); + + delete shader; + fs::remove_all(shaderRoot); +} + TEST(ShaderLoader, ResourceManagerLoadsShaderManifestRelativeToResourceRoot) { namespace fs = std::filesystem; @@ -368,6 +508,93 @@ TEST(ShaderLoader, AssetDatabaseCreatesShaderArtifactAndLoaderReadsItBack) { fs::remove_all(projectRoot); } +TEST(ShaderLoader, AssetDatabaseCreatesShaderArtifactFromUnityLikeAuthoringAndTracksStageDependencies) { + namespace fs = std::filesystem; + using namespace std::chrono_literals; + + const fs::path projectRoot = fs::temp_directory_path() / "xc_shader_authoring_artifact_test"; + const fs::path shaderDir = projectRoot / "Assets" / "Shaders"; + const fs::path stageDir = shaderDir / "stages"; + const fs::path shaderPath = shaderDir / "lit.shader"; + const fs::path fragmentPath = stageDir / "lit.frag.glsl"; + + fs::remove_all(projectRoot); + fs::create_directories(stageDir); + + WriteTextFile(stageDir / "lit.vert.glsl", "#version 430\n// AUTHORING_ARTIFACT_GL_VS\nvoid main() {}\n"); + WriteTextFile(fragmentPath, "#version 430\n// AUTHORING_ARTIFACT_GL_PS\nvoid main() {}\n"); + WriteTextFile( + shaderPath, + R"(Shader "ArtifactAuthoringShader" +{ + Properties + { + _MainTex ("Main Tex", 2D) = "white" [Semantic(BaseColorTexture)] + } + SubShader + { + Pass + { + Name "ForwardLit" + Tags { "LightMode" = "ForwardBase" } + Resources + { + BaseColorTexture (Texture2D, 3, 0) [Semantic(BaseColorTexture)] + } + HLSLPROGRAM + #pragma vertex main + #pragma fragment main + #pragma backend OpenGL GLSL "stages/lit.vert.glsl" "stages/lit.frag.glsl" + ENDHLSL + } + } +} +)"); + + AssetDatabase database; + database.Initialize(projectRoot.string().c_str()); + + AssetDatabase::ResolvedAsset firstResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/Shaders/lit.shader", ResourceType::Shader, firstResolve)); + ASSERT_TRUE(firstResolve.artifactReady); + EXPECT_EQ(fs::path(firstResolve.artifactMainPath.CStr()).extension().string(), ".xcshader"); + EXPECT_TRUE(fs::exists(firstResolve.artifactMainPath.CStr())); + + ShaderLoader loader; + LoadResult firstLoad = loader.Load(firstResolve.artifactMainPath.CStr()); + ASSERT_TRUE(firstLoad); + ASSERT_NE(firstLoad.resource, nullptr); + auto* firstShader = static_cast(firstLoad.resource); + ASSERT_NE(firstShader, nullptr); + EXPECT_EQ(firstShader->GetName(), "ArtifactAuthoringShader"); + const ShaderStageVariant* firstFragment = + firstShader->FindVariant("ForwardLit", ShaderType::Fragment, ShaderBackend::OpenGL); + ASSERT_NE(firstFragment, nullptr); + EXPECT_NE(std::string(firstFragment->sourceCode.CStr()).find("AUTHORING_ARTIFACT_GL_PS"), std::string::npos); + delete firstShader; + + const String firstArtifactPath = firstResolve.artifactMainPath; + database.Shutdown(); + + std::this_thread::sleep_for(50ms); + { + std::ofstream fragmentFile(fragmentPath, std::ios::app); + ASSERT_TRUE(fragmentFile.is_open()); + fragmentFile << "\n// force authoring dependency reimport\n"; + ASSERT_TRUE(static_cast(fragmentFile)); + } + + database.Initialize(projectRoot.string().c_str()); + AssetDatabase::ResolvedAsset secondResolve; + ASSERT_TRUE(database.EnsureArtifact("Assets/Shaders/lit.shader", ResourceType::Shader, secondResolve)); + ASSERT_TRUE(secondResolve.artifactReady); + EXPECT_NE(firstArtifactPath, secondResolve.artifactMainPath); + EXPECT_TRUE(fs::exists(secondResolve.artifactMainPath.CStr())); + database.Shutdown(); + + fs::remove_all(projectRoot); +} + TEST(ShaderLoader, AssetDatabaseReimportsShaderWhenStageDependencyChanges) { namespace fs = std::filesystem; using namespace std::chrono_literals;