diff --git a/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md b/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md index 0873a6f8..59f89578 100644 --- a/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md +++ b/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md @@ -481,3 +481,44 @@ Material 面板不应再次演化成随意堆字段的临时入口。所有 rend - 针对 Inspector 材质链路的专门自动化测试 因此,Phase 3 的完成标准是“Shader schema 驱动的属性面板已经建立起来”,但还不是最终形态。 + +## 14. Phase 4 执行结果 + +状态:已完成 + +本阶段重点处理的是 authoring-state 与 runtime-state 的一致性问题,避免 Inspector 因为单纯从运行时对象反推而破坏材质源文件的语义。 + +### 14.1 已完成内容 + +- Inspector 现在会回读材质源文件中实际 authored 的: + - `properties` + - `textures` + - `keywords` + - `renderState` +- 材质状态中已加入“是否需要序列化回源文件”的 authored 标记。 +- 保存材质时,不再无条件把所有运行时属性都写回源文件。 +- 对于未在源文件中显式 authored 的属性,当前会继续保持“继承 Shader 默认值”的语义。 +- 当用户在 Inspector 中修改属性或贴图后,对应项才会被标记为显式 authored 并写回源文件。 + +### 14.2 本阶段解决的核心问题 + +本阶段解决的是一个架构层面的隐患: + +- 如果只从运行时 `Material` 反推回材质源文件,打开并保存一次材质,就会把 Shader 默认值全部固化进 `.mat`。 +- 一旦默认值被固化,后续 Shader 默认值再调整,材质就不再继承新的默认值。 + +当前这条链路已经收口到更合理的状态: + +- 只有显式 authored 的 override 才会写回 +- 默认值仍然可以继续作为 Shader 侧的基线被继承 + +### 14.3 本阶段仍未完成的部分 + +以下内容仍然需要下一阶段继续完成: + +- 针对 Inspector 材质链路的专门自动化测试 +- 属性“重置到默认值”的正式交互 +- 关键词的可视化编辑 UI +- 更完整的属性类型/显示策略覆盖 + +因此,Phase 4 的性质是“先把 authoring 语义做正确”,为最后的测试与收口创造条件。 diff --git a/editor/src/panels/InspectorPanel.cpp b/editor/src/panels/InspectorPanel.cpp index bca286cd..3987225f 100644 --- a/editor/src/panels/InspectorPanel.cpp +++ b/editor/src/panels/InspectorPanel.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include namespace XCEngine { namespace Editor { @@ -105,6 +107,254 @@ std::string EscapeJsonString(const std::string& value) { return escaped; } +size_t SkipWhitespace(const std::string& text, size_t position) { + while (position < text.size() && std::isspace(static_cast(text[position])) != 0) { + ++position; + } + return position; +} + +bool ParseQuotedString( + const std::string& text, + size_t quotePosition, + std::string& outValue, + size_t* nextPosition = nullptr) { + if (quotePosition >= text.size() || text[quotePosition] != '"') { + return false; + } + + std::string parsed; + ++quotePosition; + while (quotePosition < text.size()) { + const char ch = text[quotePosition]; + if (ch == '\\') { + if (quotePosition + 1 >= text.size()) { + return false; + } + parsed.push_back(text[quotePosition + 1]); + quotePosition += 2; + continue; + } + if (ch == '"') { + outValue = parsed; + if (nextPosition != nullptr) { + *nextPosition = quotePosition + 1; + } + return true; + } + parsed.push_back(ch); + ++quotePosition; + } + + return false; +} + +bool FindValueStart(const std::string& text, const char* key, size_t& outValuePosition) { + const std::string token = std::string("\"") + key + "\""; + const size_t keyPosition = text.find(token); + if (keyPosition == std::string::npos) { + return false; + } + + const size_t colonPosition = text.find(':', keyPosition + token.length()); + if (colonPosition == std::string::npos) { + return false; + } + + outValuePosition = SkipWhitespace(text, colonPosition + 1); + return outValuePosition < text.size(); +} + +bool TryExtractDelimitedValue( + const std::string& text, + const char* key, + char openChar, + char closeChar, + std::string& outValue) { + size_t valuePosition = 0; + if (!FindValueStart(text, key, valuePosition) || text[valuePosition] != openChar) { + return false; + } + + int depth = 0; + bool inString = false; + size_t current = valuePosition; + while (current < text.size()) { + const char ch = text[current]; + if (ch == '"' && (current == 0 || text[current - 1] != '\\')) { + inString = !inString; + } + if (!inString) { + if (ch == openChar) { + ++depth; + } else if (ch == closeChar) { + --depth; + if (depth == 0) { + outValue = text.substr(valuePosition, current - valuePosition + 1); + return true; + } + } + } + ++current; + } + + return false; +} + +bool TryExtractObject(const std::string& text, const char* key, std::string& outValue) { + return TryExtractDelimitedValue(text, key, '{', '}', outValue); +} + +bool TryExtractArray(const std::string& text, const char* key, std::string& outValue) { + return TryExtractDelimitedValue(text, key, '[', ']', outValue); +} + +bool CollectObjectKeys(const std::string& objectText, std::unordered_set& outKeys) { + if (objectText.empty() || objectText.front() != '{' || objectText.back() != '}') { + return false; + } + + size_t position = 1; + while (position < objectText.size()) { + position = SkipWhitespace(objectText, position); + if (position >= objectText.size()) { + return false; + } + if (objectText[position] == '}') { + return true; + } + + std::string key; + if (!ParseQuotedString(objectText, position, key, &position)) { + return false; + } + outKeys.insert(key); + + position = SkipWhitespace(objectText, position); + if (position >= objectText.size() || objectText[position] != ':') { + return false; + } + + position = SkipWhitespace(objectText, position + 1); + if (position >= objectText.size()) { + return false; + } + + if (objectText[position] == '"') { + std::string ignoredValue; + if (!ParseQuotedString(objectText, position, ignoredValue, &position)) { + return false; + } + } else if (objectText[position] == '[') { + int depth = 0; + bool inString = false; + do { + const char ch = objectText[position]; + if (ch == '"' && (position == 0 || objectText[position - 1] != '\\')) { + inString = !inString; + } + if (!inString) { + if (ch == '[') { + ++depth; + } else if (ch == ']') { + --depth; + } + } + ++position; + } while (position < objectText.size() && depth > 0); + } else { + while (position < objectText.size() && + objectText[position] != ',' && + objectText[position] != '}') { + ++position; + } + } + + position = SkipWhitespace(objectText, position); + if (position < objectText.size() && objectText[position] == ',') { + ++position; + } + } + + return false; +} + +bool CollectStringArrayValues(const std::string& arrayText, std::unordered_set& outValues) { + if (arrayText.empty() || arrayText.front() != '[' || arrayText.back() != ']') { + return false; + } + + size_t position = 1; + while (position < arrayText.size()) { + position = SkipWhitespace(arrayText, position); + if (position >= arrayText.size()) { + return false; + } + if (arrayText[position] == ']') { + return true; + } + + std::string value; + if (!ParseQuotedString(arrayText, position, value, &position)) { + return false; + } + if (!TrimCopy(value).empty()) { + outValues.insert(TrimCopy(value)); + } + + position = SkipWhitespace(arrayText, position); + if (position < arrayText.size() && arrayText[position] == ',') { + ++position; + } + } + + return false; +} + +struct MaterialAuthoringPresence { + bool hasRenderStateOverride = false; + std::unordered_set keywordValues; + std::unordered_set propertyKeys; + std::unordered_set textureKeys; +}; + +MaterialAuthoringPresence ParseMaterialAuthoringPresence(const std::string& text) { + MaterialAuthoringPresence presence; + presence.hasRenderStateOverride = text.find("\"renderState\"") != std::string::npos; + + std::string propertiesObject; + if (TryExtractObject(text, "properties", propertiesObject)) { + CollectObjectKeys(propertiesObject, presence.propertyKeys); + } + + std::string texturesObject; + if (TryExtractObject(text, "textures", texturesObject)) { + CollectObjectKeys(texturesObject, presence.textureKeys); + } + + std::string keywordsArray; + if (TryExtractArray(text, "keywords", keywordsArray)) { + CollectStringArrayValues(keywordsArray, presence.keywordValues); + } + + return presence; +} + +std::string ReadTextFileOrEmpty(const std::string& path) { + if (path.empty()) { + return std::string(); + } + + std::ifstream input(std::filesystem::path(Platform::Utf8ToWide(path)), std::ios::in | std::ios::binary); + if (!input.is_open()) { + return std::string(); + } + + return std::string( + (std::istreambuf_iterator(input)), + std::istreambuf_iterator()); +} + struct InspectorAssetReferenceInteraction { std::string assignedPath; bool clearRequested = false; @@ -750,6 +1000,7 @@ void CopyMaterialPropertyValue( destination.intValue = source.intValue; destination.boolValue = source.boolValue; destination.texturePath = source.texturePath; + destination.serialized = source.serialized; } std::vector BuildShaderDefaultPropertyStates( @@ -807,6 +1058,24 @@ void SyncMaterialAssetStateWithShader( state.keywords = std::move(nextKeywords); } +void ApplyMaterialAuthoringPresenceToState( + const MaterialAuthoringPresence& presence, + InspectorPanel::MaterialAssetState& state) { + state.hasRenderStateOverride = presence.hasRenderStateOverride; + + for (InspectorPanel::MaterialKeywordState& keyword : state.keywords) { + keyword.serialized = presence.keywordValues.find(TrimCopy(keyword.value)) != presence.keywordValues.end(); + } + + for (InspectorPanel::MaterialPropertyState& property : state.properties) { + if (IsTextureMaterialPropertyType(property.type)) { + property.serialized = presence.textureKeys.find(property.name) != presence.textureKeys.end(); + } else { + property.serialized = presence.propertyKeys.find(property.name) != presence.propertyKeys.end(); + } + } +} + InspectorPanel::MaterialPropertyState* FindMaterialPropertyState( InspectorPanel::MaterialAssetState& state, const Containers::String& propertyName) { @@ -846,7 +1115,11 @@ bool DrawShaderDrivenMaterialProperty( switch (shaderProperty.type) { case ::XCEngine::Resources::ShaderPropertyType::Float: case ::XCEngine::Resources::ShaderPropertyType::Range: - return UI::DrawPropertyFloat(label, propertyState->floatValue[0], 0.01f); + if (!UI::DrawPropertyFloat(label, propertyState->floatValue[0], 0.01f)) { + return false; + } + propertyState->serialized = true; + return true; case ::XCEngine::Resources::ShaderPropertyType::Int: { int value = propertyState->intValue[0]; @@ -854,11 +1127,16 @@ bool DrawShaderDrivenMaterialProperty( return false; } propertyState->intValue[0] = value; + propertyState->serialized = true; return true; } case ::XCEngine::Resources::ShaderPropertyType::Color: - return UI::DrawPropertyColor4(label, propertyState->floatValue.data()); + if (!UI::DrawPropertyColor4(label, propertyState->floatValue.data())) { + return false; + } + propertyState->serialized = true; + return true; case ::XCEngine::Resources::ShaderPropertyType::Vector: { if (propertyState->type == ::XCEngine::Resources::MaterialPropertyType::Float2) { @@ -868,6 +1146,7 @@ bool DrawShaderDrivenMaterialProperty( } propertyState->floatValue[0] = value.x; propertyState->floatValue[1] = value.y; + propertyState->serialized = true; return true; } if (propertyState->type == ::XCEngine::Resources::MaterialPropertyType::Float3) { @@ -881,9 +1160,14 @@ bool DrawShaderDrivenMaterialProperty( propertyState->floatValue[0] = value.x; propertyState->floatValue[1] = value.y; propertyState->floatValue[2] = value.z; + propertyState->serialized = true; return true; } - return DrawFloat4Property(label, propertyState->floatValue.data()); + if (!DrawFloat4Property(label, propertyState->floatValue.data())) { + return false; + } + propertyState->serialized = true; + return true; } case ::XCEngine::Resources::ShaderPropertyType::Texture2D: @@ -899,11 +1183,13 @@ bool DrawShaderDrivenMaterialProperty( return false; } propertyState->texturePath.clear(); + propertyState->serialized = false; return true; } if (!textureInteraction.assignedPath.empty() && textureInteraction.assignedPath != propertyState->texturePath) { propertyState->texturePath = textureInteraction.assignedPath; + propertyState->serialized = true; return true; } return false; @@ -980,6 +1266,9 @@ std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState& std::string keywordsArray = " \"keywords\": ["; bool firstKeyword = true; for (const InspectorPanel::MaterialKeywordState& keyword : state.keywords) { + if (!keyword.serialized) { + continue; + } const std::string keywordValue = TrimCopy(keyword.value); if (keywordValue.empty()) { continue; @@ -1004,6 +1293,9 @@ std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState& } if (IsTextureMaterialPropertyType(property.type)) { + if (!property.serialized) { + continue; + } const std::string texturePath = TrimCopy(property.texturePath); if (!texturePath.empty()) { textureEntries.push_back( @@ -1013,6 +1305,9 @@ std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState& continue; } + if (!property.serialized) { + continue; + } const std::string propertyValueText = BuildMaterialPropertyValueText(property); if (!propertyValueText.empty()) { propertyEntries.push_back( @@ -1228,6 +1523,13 @@ void InspectorPanel::PopulateMaterialAssetStateFromResource(::XCEngine::Resource m_materialAssetState.properties = CollectMaterialPropertyStates(material); + const std::string sourceText = ReadTextFileOrEmpty(m_materialAssetState.assetFullPath); + if (!sourceText.empty()) { + ApplyMaterialAuthoringPresenceToState( + ParseMaterialAuthoringPresence(sourceText), + m_materialAssetState); + } + m_materialAssetState.loaded = true; m_materialAssetState.dirty = false; } diff --git a/editor/src/panels/InspectorPanel.h b/editor/src/panels/InspectorPanel.h index a5b3ddae..4b8cc234 100644 --- a/editor/src/panels/InspectorPanel.h +++ b/editor/src/panels/InspectorPanel.h @@ -45,6 +45,7 @@ public: struct MaterialKeywordState { std::string value; + bool serialized = false; }; struct MaterialPropertyState { @@ -55,6 +56,7 @@ public: std::array<::XCEngine::Core::int32, 4> intValue{}; bool boolValue = false; std::string texturePath; + bool serialized = false; }; struct MaterialAssetState {