diff --git a/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md b/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md index 59f89578..1f3b44fe 100644 --- a/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md +++ b/docs/plan/Material Inspector与Shader属性面板收口计划_2026-04-07.md @@ -522,3 +522,46 @@ Material 面板不应再次演化成随意堆字段的临时入口。所有 rend - 更完整的属性类型/显示策略覆盖 因此,Phase 4 的性质是“先把 authoring 语义做正确”,为最后的测试与收口创造条件。 + +## 15. Phase 5 执行结果 + +状态:已完成 + +本阶段重点不是继续扩材质面板功能,而是把已经形成的正式链路变成“可持续回归验证”的状态,并把测试面收口到 Inspector 实际依赖的核心逻辑上。 + +### 15.1 已完成内容 + +- 新增 `MaterialInspectorMaterialState` / `MaterialInspectorMaterialStateIO` 辅助模块,承载: + - Material Inspector 状态结构 + - source authored presence 解析 + - Shader 切换时的属性/关键词重同步 + - 材质源文件序列化文本生成 +- 新增 `tests/editor/test_material_inspector_material_state_io.cpp`,覆盖: + - authored 属性/纹理/关键词标记识别 + - Shader 切换时保留兼容 override、清理陈旧字段 + - 非 authored 默认值不写回源文件 + - 纹理 override 与 render state override 的正式序列化 +- `editor_tests` 构建链路已接入上述 helper 与新测试文件。 + +### 15.2 已执行验证 + +- `cmake --build build --config Debug --target XCEditor -j 8` +- `cmake --build build --config Debug --target editor_tests -- /v:minimal` +- `build/tests/Resources/Material/Debug/material_tests.exe` +- `build/tests/Core/Asset/Debug/asset_tests.exe` +- `build/tests/Editor/Debug/editor_tests.exe --gtest_filter=MaterialInspectorMaterialStateIOTest.*` + +验证结果: + +- `XCEditor` 编译通过 +- `material_tests`:61 / 61 通过 +- `asset_tests`:56 / 56 通过 +- `MaterialInspectorMaterialStateIOTest`:4 / 4 通过 + +### 15.3 当前剩余风险 + +- 全量 `editor_tests.exe` 在顺序执行 `EditorActionRoutingTest.*` 时仍存在既有的共享状态级别挂起现象。 +- 该挂起并非本次新增的材质面板测试本身触发: + - 新增 `MaterialInspectorMaterialStateIOTest` 单独执行通过 + - `EditorActionRoutingTest.PlayModeAllowsRuntimeSceneUndoRedoButKeepsSceneDocumentCommandsBlocked` 单独执行通过 +- 因此,本阶段围绕 Material Inspector / Shader 属性面板的测试收口已经完成,但 Editor 其余历史测试链路仍需单独排查。 diff --git a/editor/src/panels/InspectorPanel.cpp b/editor/src/panels/InspectorPanel.cpp index 3987225f..f88f426a 100644 --- a/editor/src/panels/InspectorPanel.cpp +++ b/editor/src/panels/InspectorPanel.cpp @@ -3,6 +3,7 @@ #include "Actions/EditorActions.h" #include "Commands/ProjectCommands.h" #include "InspectorPanel.h" +#include "MaterialInspectorMaterialStateIO.h" #include "Core/AssetItem.h" #include "Core/IEditorContext.h" #include "Core/IProjectManager.h" @@ -28,8 +29,6 @@ #include #include #include -#include -#include namespace XCEngine { namespace Editor { @@ -79,267 +78,6 @@ std::string TrimCopy(const std::string& value) { return ProjectFileUtils::Trim(value); } -std::string EscapeJsonString(const std::string& value) { - std::string escaped; - escaped.reserve(value.size()); - for (const char ch : value) { - switch (ch) { - case '\\': - escaped += "\\\\"; - break; - case '"': - escaped += "\\\""; - break; - case '\n': - escaped += "\\n"; - break; - case '\r': - escaped += "\\r"; - break; - case '\t': - escaped += "\\t"; - break; - default: - escaped.push_back(ch); - break; - } - } - 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(); @@ -704,167 +442,6 @@ const char* SerializeStencilOp(::XCEngine::Resources::MaterialStencilOp op) { } } -bool IsTextureMaterialPropertyType(::XCEngine::Resources::MaterialPropertyType type) { - return type == ::XCEngine::Resources::MaterialPropertyType::Texture || - type == ::XCEngine::Resources::MaterialPropertyType::Cubemap; -} - -int GetMaterialPropertyComponentCount(::XCEngine::Resources::MaterialPropertyType type) { - switch (type) { - case ::XCEngine::Resources::MaterialPropertyType::Float2: - case ::XCEngine::Resources::MaterialPropertyType::Int2: - return 2; - case ::XCEngine::Resources::MaterialPropertyType::Float3: - case ::XCEngine::Resources::MaterialPropertyType::Int3: - return 3; - case ::XCEngine::Resources::MaterialPropertyType::Float4: - case ::XCEngine::Resources::MaterialPropertyType::Int4: - return 4; - case ::XCEngine::Resources::MaterialPropertyType::Float: - case ::XCEngine::Resources::MaterialPropertyType::Int: - case ::XCEngine::Resources::MaterialPropertyType::Bool: - case ::XCEngine::Resources::MaterialPropertyType::Texture: - case ::XCEngine::Resources::MaterialPropertyType::Cubemap: - default: - return 1; - } -} - -std::string FormatJsonFloat(float value) { - char buffer[32] = {}; - std::snprintf(buffer, sizeof(buffer), "%.6g", static_cast(value)); - return std::string(buffer); -} - -std::string BuildMaterialPropertyValueText(const InspectorPanel::MaterialPropertyState& property) { - using PropertyType = ::XCEngine::Resources::MaterialPropertyType; - - switch (property.type) { - case PropertyType::Float: - return FormatJsonFloat(property.floatValue[0]); - case PropertyType::Float2: - case PropertyType::Float3: - case PropertyType::Float4: { - const int componentCount = GetMaterialPropertyComponentCount(property.type); - std::string text = "["; - for (int componentIndex = 0; componentIndex < componentCount; ++componentIndex) { - if (componentIndex > 0) { - text += ", "; - } - text += FormatJsonFloat(property.floatValue[componentIndex]); - } - text += "]"; - return text; - } - case PropertyType::Int: - return std::to_string(property.intValue[0]); - case PropertyType::Int2: - case PropertyType::Int3: - case PropertyType::Int4: { - const int componentCount = GetMaterialPropertyComponentCount(property.type); - std::string text = "["; - for (int componentIndex = 0; componentIndex < componentCount; ++componentIndex) { - if (componentIndex > 0) { - text += ", "; - } - text += std::to_string(property.intValue[componentIndex]); - } - text += "]"; - return text; - } - case PropertyType::Bool: - return property.boolValue ? "true" : "false"; - case PropertyType::Texture: - case PropertyType::Cubemap: - default: - return std::string(); - } -} - -bool TryResolveMaterialTextureBindingPath( - const ::XCEngine::Resources::Material& material, - const Containers::String& propertyName, - std::string& outPath) { - for (Core::uint32 bindingIndex = 0; bindingIndex < material.GetTextureBindingCount(); ++bindingIndex) { - if (material.GetTextureBindingName(bindingIndex) != propertyName) { - continue; - } - - Containers::String bindingPath = material.GetTextureBindingPath(bindingIndex); - if (bindingPath.Empty()) { - const ::XCEngine::Resources::AssetRef assetRef = material.GetTextureBindingAssetRef(bindingIndex); - if (assetRef.IsValid()) { - ::XCEngine::Resources::ResourceManager::Get().TryResolveAssetPath(assetRef, bindingPath); - } - } - - outPath = std::string(bindingPath.CStr()); - return true; - } - - return false; -} - -InspectorPanel::MaterialPropertyState BuildMaterialPropertyState( - const ::XCEngine::Resources::MaterialProperty& property, - const ::XCEngine::Resources::Material& material) { - InspectorPanel::MaterialPropertyState state; - state.name = std::string(property.name.CStr()); - state.type = property.type; - for (size_t index = 0; index < state.floatValue.size(); ++index) { - state.floatValue[index] = property.value.floatValue[index]; - state.intValue[index] = property.value.intValue[index]; - } - state.boolValue = property.value.boolValue; - if (IsTextureMaterialPropertyType(property.type)) { - TryResolveMaterialTextureBindingPath(material, property.name, state.texturePath); - } - return state; -} - -std::vector CollectMaterialPropertyStates( - const ::XCEngine::Resources::Material& material) { - std::vector<::XCEngine::Resources::MaterialProperty> properties = material.GetProperties(); - const ::XCEngine::Resources::Shader* shader = material.GetShader(); - if (shader != nullptr && !shader->GetProperties().Empty()) { - std::unordered_map shaderPropertyOrder; - shaderPropertyOrder.reserve(shader->GetProperties().Size()); - for (size_t propertyIndex = 0; propertyIndex < shader->GetProperties().Size(); ++propertyIndex) { - shaderPropertyOrder.emplace(std::string(shader->GetProperties()[propertyIndex].name.CStr()), propertyIndex); - } - - std::sort( - properties.begin(), - properties.end(), - [&shaderPropertyOrder](const auto& left, const auto& right) { - const std::string leftName(left.name.CStr()); - const std::string rightName(right.name.CStr()); - const auto leftIt = shaderPropertyOrder.find(leftName); - const auto rightIt = shaderPropertyOrder.find(rightName); - const size_t leftOrder = leftIt != shaderPropertyOrder.end() ? leftIt->second : shaderPropertyOrder.size(); - const size_t rightOrder = rightIt != shaderPropertyOrder.end() ? rightIt->second : shaderPropertyOrder.size(); - if (leftOrder != rightOrder) { - return leftOrder < rightOrder; - } - return leftName < rightName; - }); - } else { - std::sort( - properties.begin(), - properties.end(), - [](const auto& left, const auto& right) { - return std::strcmp(left.name.CStr(), right.name.CStr()) < 0; - }); - } - - std::vector states; - states.reserve(properties.size()); - for (const ::XCEngine::Resources::MaterialProperty& property : properties) { - states.push_back(BuildMaterialPropertyState(property, material)); - } - return states; -} - void ApplyMaterialKeywordsToResource( const InspectorPanel::MaterialAssetState& state, ::XCEngine::Resources::Material& material) { @@ -992,90 +569,6 @@ bool TryResolveShaderHandle( return outShader.IsValid(); } -void CopyMaterialPropertyValue( - const InspectorPanel::MaterialPropertyState& source, - InspectorPanel::MaterialPropertyState& destination) { - destination.type = source.type; - destination.floatValue = source.floatValue; - destination.intValue = source.intValue; - destination.boolValue = source.boolValue; - destination.texturePath = source.texturePath; - destination.serialized = source.serialized; -} - -std::vector BuildShaderDefaultPropertyStates( - const ::XCEngine::Resources::ResourceHandle<::XCEngine::Resources::Shader>& shaderHandle) { - if (!shaderHandle.IsValid()) { - return {}; - } - - ::XCEngine::Resources::Material scratchMaterial; - scratchMaterial.SetShader(shaderHandle); - return CollectMaterialPropertyStates(scratchMaterial); -} - -void SyncMaterialAssetStateWithShader( - const ::XCEngine::Resources::ResourceHandle<::XCEngine::Resources::Shader>& shaderHandle, - InspectorPanel::MaterialAssetState& state) { - if (!shaderHandle.IsValid() || shaderHandle.Get() == nullptr) { - state.keywords.clear(); - state.properties.clear(); - return; - } - - std::unordered_map previousProperties; - previousProperties.reserve(state.properties.size()); - for (const InspectorPanel::MaterialPropertyState& property : state.properties) { - if (!property.name.empty()) { - previousProperties.emplace(property.name, property); - } - } - - std::vector nextProperties = - BuildShaderDefaultPropertyStates(shaderHandle); - for (InspectorPanel::MaterialPropertyState& property : nextProperties) { - const auto previousPropertyIt = previousProperties.find(property.name); - if (previousPropertyIt == previousProperties.end()) { - continue; - } - - if (previousPropertyIt->second.type != property.type) { - continue; - } - - CopyMaterialPropertyValue(previousPropertyIt->second, property); - } - state.properties = std::move(nextProperties); - - std::vector nextKeywords; - nextKeywords.reserve(state.keywords.size()); - for (const InspectorPanel::MaterialKeywordState& keyword : state.keywords) { - const std::string keywordValue = TrimCopy(keyword.value); - if (!keywordValue.empty() && shaderHandle->DeclaresKeyword(keywordValue.c_str())) { - nextKeywords.push_back(keyword); - } - } - 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) { @@ -1223,212 +716,6 @@ std::string BuildShaderLoadFailureMessage( return message; } -std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState& state) { - std::vector rootEntries; - - const std::string shaderPath = TrimCopy(BufferToString(state.shaderPath)); - if (!shaderPath.empty()) { - rootEntries.push_back(" \"shader\": \"" + EscapeJsonString(shaderPath) + "\""); - } - - const int renderQueuePreset = ResolveRenderQueuePresetIndex(state.renderQueue); - if (renderQueuePreset != kCustomRenderQueuePresetIndex) { - rootEntries.push_back( - " \"renderQueue\": \"" + std::string(SerializeRenderQueue(state.renderQueue)) + "\""); - } else { - rootEntries.push_back(" \"renderQueue\": " + std::to_string(state.renderQueue)); - } - - std::vector tagEntries; - for (const InspectorPanel::MaterialTagEditRow& row : state.tags) { - const std::string tagName = TrimCopy(BufferToString(row.name)); - if (tagName.empty()) { - continue; - } - tagEntries.push_back( - " \"" + EscapeJsonString(tagName) + "\": \"" + - EscapeJsonString(TrimCopy(BufferToString(row.value))) + "\""); - } - if (!tagEntries.empty()) { - std::string tagsObject = " \"tags\": {\n"; - for (size_t tagIndex = 0; tagIndex < tagEntries.size(); ++tagIndex) { - tagsObject += tagEntries[tagIndex]; - if (tagIndex + 1 < tagEntries.size()) { - tagsObject += ","; - } - tagsObject += "\n"; - } - tagsObject += " }"; - rootEntries.push_back(tagsObject); - } - - if (!state.keywords.empty()) { - 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; - } - if (!firstKeyword) { - keywordsArray += ", "; - } - keywordsArray += "\"" + EscapeJsonString(keywordValue) + "\""; - firstKeyword = false; - } - keywordsArray += "]"; - if (!firstKeyword) { - rootEntries.push_back(keywordsArray); - } - } - - std::vector propertyEntries; - std::vector textureEntries; - for (const InspectorPanel::MaterialPropertyState& property : state.properties) { - if (property.name.empty()) { - continue; - } - - if (IsTextureMaterialPropertyType(property.type)) { - if (!property.serialized) { - continue; - } - const std::string texturePath = TrimCopy(property.texturePath); - if (!texturePath.empty()) { - textureEntries.push_back( - " \"" + EscapeJsonString(property.name) + "\": \"" + - EscapeJsonString(texturePath) + "\""); - } - continue; - } - - if (!property.serialized) { - continue; - } - const std::string propertyValueText = BuildMaterialPropertyValueText(property); - if (!propertyValueText.empty()) { - propertyEntries.push_back( - " \"" + EscapeJsonString(property.name) + "\": " + propertyValueText); - } - } - - if (!propertyEntries.empty()) { - std::string propertiesObject = " \"properties\": {\n"; - for (size_t propertyIndex = 0; propertyIndex < propertyEntries.size(); ++propertyIndex) { - propertiesObject += propertyEntries[propertyIndex]; - if (propertyIndex + 1 < propertyEntries.size()) { - propertiesObject += ","; - } - propertiesObject += "\n"; - } - propertiesObject += " }"; - rootEntries.push_back(propertiesObject); - } - - if (!textureEntries.empty()) { - std::string texturesObject = " \"textures\": {\n"; - for (size_t textureIndex = 0; textureIndex < textureEntries.size(); ++textureIndex) { - texturesObject += textureEntries[textureIndex]; - if (textureIndex + 1 < textureEntries.size()) { - texturesObject += ","; - } - texturesObject += "\n"; - } - texturesObject += " }"; - rootEntries.push_back(texturesObject); - } - - if (state.hasRenderStateOverride) { - const ::XCEngine::Resources::MaterialRenderState& renderState = state.renderState; - std::vector renderStateEntries; - renderStateEntries.push_back( - " \"cull\": \"" + std::string(SerializeCullMode(renderState.cullMode)) + "\""); - renderStateEntries.push_back(std::string(" \"depthTest\": ") + (renderState.depthTestEnable ? "true" : "false")); - renderStateEntries.push_back(std::string(" \"depthWrite\": ") + (renderState.depthWriteEnable ? "true" : "false")); - renderStateEntries.push_back( - " \"depthFunc\": \"" + std::string(SerializeComparisonFunc(renderState.depthFunc)) + "\""); - renderStateEntries.push_back(std::string(" \"blendEnable\": ") + (renderState.blendEnable ? "true" : "false")); - renderStateEntries.push_back( - " \"srcBlend\": \"" + std::string(SerializeBlendFactor(renderState.srcBlend)) + "\""); - renderStateEntries.push_back( - " \"dstBlend\": \"" + std::string(SerializeBlendFactor(renderState.dstBlend)) + "\""); - renderStateEntries.push_back( - " \"srcBlendAlpha\": \"" + std::string(SerializeBlendFactor(renderState.srcBlendAlpha)) + "\""); - renderStateEntries.push_back( - " \"dstBlendAlpha\": \"" + std::string(SerializeBlendFactor(renderState.dstBlendAlpha)) + "\""); - renderStateEntries.push_back( - " \"blendOp\": \"" + std::string(SerializeBlendOp(renderState.blendOp)) + "\""); - renderStateEntries.push_back( - " \"blendOpAlpha\": \"" + std::string(SerializeBlendOp(renderState.blendOpAlpha)) + "\""); - renderStateEntries.push_back(" \"colorWriteMask\": " + std::to_string(renderState.colorWriteMask)); - if (renderState.depthBiasFactor != 0.0f || renderState.depthBiasUnits != 0) { - renderStateEntries.push_back( - " \"offset\": [" + - FormatJsonFloat(renderState.depthBiasFactor) + ", " + - std::to_string(renderState.depthBiasUnits) + "]"); - } - if (renderState.stencil.enabled) { - std::vector stencilEntries; - stencilEntries.push_back(" \"enabled\": true"); - stencilEntries.push_back(" \"ref\": " + std::to_string(renderState.stencil.reference)); - stencilEntries.push_back(" \"readMask\": " + std::to_string(renderState.stencil.readMask)); - stencilEntries.push_back(" \"writeMask\": " + std::to_string(renderState.stencil.writeMask)); - stencilEntries.push_back( - " \"comp\": \"" + std::string(SerializeComparisonFunc(renderState.stencil.front.func)) + "\""); - stencilEntries.push_back( - " \"pass\": \"" + std::string(SerializeStencilOp(renderState.stencil.front.passOp)) + "\""); - stencilEntries.push_back( - " \"fail\": \"" + std::string(SerializeStencilOp(renderState.stencil.front.failOp)) + "\""); - stencilEntries.push_back( - " \"zFail\": \"" + std::string(SerializeStencilOp(renderState.stencil.front.depthFailOp)) + "\""); - stencilEntries.push_back( - " \"compBack\": \"" + std::string(SerializeComparisonFunc(renderState.stencil.back.func)) + "\""); - stencilEntries.push_back( - " \"passBack\": \"" + std::string(SerializeStencilOp(renderState.stencil.back.passOp)) + "\""); - stencilEntries.push_back( - " \"failBack\": \"" + std::string(SerializeStencilOp(renderState.stencil.back.failOp)) + "\""); - stencilEntries.push_back( - " \"zFailBack\": \"" + std::string(SerializeStencilOp(renderState.stencil.back.depthFailOp)) + "\""); - - std::string stencilObject = " \"stencil\": {\n"; - for (size_t stencilIndex = 0; stencilIndex < stencilEntries.size(); ++stencilIndex) { - stencilObject += stencilEntries[stencilIndex]; - if (stencilIndex + 1 < stencilEntries.size()) { - stencilObject += ","; - } - stencilObject += "\n"; - } - stencilObject += " }"; - renderStateEntries.push_back(stencilObject); - } - - std::string renderStateObject = " \"renderState\": {\n"; - for (size_t renderStateIndex = 0; renderStateIndex < renderStateEntries.size(); ++renderStateIndex) { - renderStateObject += renderStateEntries[renderStateIndex]; - if (renderStateIndex + 1 < renderStateEntries.size()) { - renderStateObject += ","; - } - renderStateObject += "\n"; - } - renderStateObject += " }"; - rootEntries.push_back(renderStateObject); - } - - std::string json = "{\n"; - for (size_t entryIndex = 0; entryIndex < rootEntries.size(); ++entryIndex) { - json += rootEntries[entryIndex]; - if (entryIndex + 1 < rootEntries.size()) { - json += ","; - } - json += "\n"; - } - json += "}\n"; - return json; -} - } // namespace InspectorPanel::InspectorPanel() : Panel("Inspector") {} @@ -1525,9 +812,7 @@ void InspectorPanel::PopulateMaterialAssetStateFromResource(::XCEngine::Resource const std::string sourceText = ReadTextFileOrEmpty(m_materialAssetState.assetFullPath); if (!sourceText.empty()) { - ApplyMaterialAuthoringPresenceToState( - ParseMaterialAuthoringPresence(sourceText), - m_materialAssetState); + ApplyMaterialAuthoringPresenceToState(sourceText, m_materialAssetState); } m_materialAssetState.loaded = true; diff --git a/editor/src/panels/InspectorPanel.h b/editor/src/panels/InspectorPanel.h index 4b8cc234..0fb790c6 100644 --- a/editor/src/panels/InspectorPanel.h +++ b/editor/src/panels/InspectorPanel.h @@ -1,5 +1,6 @@ #pragma once +#include "MaterialInspectorMaterialState.h" #include "Panel.h" #include "Core/AssetItem.h" #include "Core/EditorActionRoute.h" @@ -38,58 +39,10 @@ public: UnsupportedAsset }; - struct MaterialTagEditRow { - std::array name{}; - std::array value{}; - }; - - struct MaterialKeywordState { - std::string value; - bool serialized = false; - }; - - struct MaterialPropertyState { - std::string name; - ::XCEngine::Resources::MaterialPropertyType type = - ::XCEngine::Resources::MaterialPropertyType::Float; - std::array floatValue{}; - std::array<::XCEngine::Core::int32, 4> intValue{}; - bool boolValue = false; - std::string texturePath; - bool serialized = false; - }; - - struct MaterialAssetState { - std::string assetPath; - std::string assetFullPath; - std::string assetName; - std::array shaderPath{}; - int renderQueue = static_cast(::XCEngine::Resources::MaterialRenderQueue::Geometry); - ::XCEngine::Resources::MaterialRenderState renderState{}; - bool hasRenderStateOverride = false; - std::vector tags; - std::vector keywords; - std::vector properties; - std::string errorMessage; - bool dirty = false; - bool loaded = false; - - void Reset() { - assetPath.clear(); - assetFullPath.clear(); - assetName.clear(); - shaderPath.fill('\0'); - renderQueue = static_cast(::XCEngine::Resources::MaterialRenderQueue::Geometry); - renderState = ::XCEngine::Resources::MaterialRenderState(); - hasRenderStateOverride = false; - tags.clear(); - keywords.clear(); - properties.clear(); - errorMessage.clear(); - dirty = false; - loaded = false; - } - }; + using MaterialTagEditRow = ::XCEngine::Editor::MaterialTagEditRow; + using MaterialKeywordState = ::XCEngine::Editor::MaterialKeywordState; + using MaterialPropertyState = ::XCEngine::Editor::MaterialPropertyState; + using MaterialAssetState = ::XCEngine::Editor::MaterialAssetState; private: void SyncSubject(); diff --git a/editor/src/panels/MaterialInspectorMaterialState.h b/editor/src/panels/MaterialInspectorMaterialState.h new file mode 100644 index 00000000..81e381ae --- /dev/null +++ b/editor/src/panels/MaterialInspectorMaterialState.h @@ -0,0 +1,66 @@ +#pragma once + +#include + +#include +#include +#include + +namespace XCEngine { +namespace Editor { + +struct MaterialTagEditRow { + std::array name{}; + std::array value{}; +}; + +struct MaterialKeywordState { + std::string value; + bool serialized = false; +}; + +struct MaterialPropertyState { + std::string name; + ::XCEngine::Resources::MaterialPropertyType type = + ::XCEngine::Resources::MaterialPropertyType::Float; + std::array floatValue{}; + std::array<::XCEngine::Core::int32, 4> intValue{}; + bool boolValue = false; + std::string texturePath; + bool serialized = false; +}; + +struct MaterialAssetState { + std::string assetPath; + std::string assetFullPath; + std::string assetName; + std::array shaderPath{}; + int renderQueue = static_cast(::XCEngine::Resources::MaterialRenderQueue::Geometry); + ::XCEngine::Resources::MaterialRenderState renderState{}; + bool hasRenderStateOverride = false; + std::vector tags; + std::vector keywords; + std::vector properties; + std::string errorMessage; + bool dirty = false; + bool loaded = false; + + void Reset() { + assetPath.clear(); + assetFullPath.clear(); + assetName.clear(); + shaderPath.fill('\0'); + renderQueue = static_cast(::XCEngine::Resources::MaterialRenderQueue::Geometry); + renderState = ::XCEngine::Resources::MaterialRenderState(); + hasRenderStateOverride = false; + tags.clear(); + keywords.clear(); + properties.clear(); + errorMessage.clear(); + dirty = false; + loaded = false; + } +}; + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/panels/MaterialInspectorMaterialStateIO.cpp b/editor/src/panels/MaterialInspectorMaterialStateIO.cpp new file mode 100644 index 00000000..39be9d81 --- /dev/null +++ b/editor/src/panels/MaterialInspectorMaterialStateIO.cpp @@ -0,0 +1,893 @@ +#include "MaterialInspectorMaterialStateIO.h" + +#include "Utils/ProjectFileUtils.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Editor { + +namespace { + +template +std::string BufferToString(const std::array& buffer) { + return std::string(buffer.data()); +} + +std::string TrimCopy(const std::string& value) { + return ProjectFileUtils::Trim(value); +} + +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; +} + +int GetMaterialPropertyComponentCount(::XCEngine::Resources::MaterialPropertyType type) { + switch (type) { + case ::XCEngine::Resources::MaterialPropertyType::Float2: + case ::XCEngine::Resources::MaterialPropertyType::Int2: + return 2; + case ::XCEngine::Resources::MaterialPropertyType::Float3: + case ::XCEngine::Resources::MaterialPropertyType::Int3: + return 3; + case ::XCEngine::Resources::MaterialPropertyType::Float4: + case ::XCEngine::Resources::MaterialPropertyType::Int4: + return 4; + case ::XCEngine::Resources::MaterialPropertyType::Float: + case ::XCEngine::Resources::MaterialPropertyType::Int: + case ::XCEngine::Resources::MaterialPropertyType::Bool: + case ::XCEngine::Resources::MaterialPropertyType::Texture: + case ::XCEngine::Resources::MaterialPropertyType::Cubemap: + default: + return 1; + } +} + +std::string FormatJsonFloat(float value) { + char buffer[32] = {}; + std::snprintf(buffer, sizeof(buffer), "%.6g", static_cast(value)); + return std::string(buffer); +} + +std::string EscapeJsonString(const std::string& value) { + std::string escaped; + escaped.reserve(value.size()); + for (const char ch : value) { + switch (ch) { + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + escaped.push_back(ch); + break; + } + } + return escaped; +} + +std::string BuildMaterialPropertyValueText(const MaterialPropertyState& property) { + using PropertyType = ::XCEngine::Resources::MaterialPropertyType; + + switch (property.type) { + case PropertyType::Float: + return FormatJsonFloat(property.floatValue[0]); + case PropertyType::Float2: + case PropertyType::Float3: + case PropertyType::Float4: { + const int componentCount = GetMaterialPropertyComponentCount(property.type); + std::string text = "["; + for (int componentIndex = 0; componentIndex < componentCount; ++componentIndex) { + if (componentIndex > 0) { + text += ", "; + } + text += FormatJsonFloat(property.floatValue[componentIndex]); + } + text += "]"; + return text; + } + case PropertyType::Int: + return std::to_string(property.intValue[0]); + case PropertyType::Int2: + case PropertyType::Int3: + case PropertyType::Int4: { + const int componentCount = GetMaterialPropertyComponentCount(property.type); + std::string text = "["; + for (int componentIndex = 0; componentIndex < componentCount; ++componentIndex) { + if (componentIndex > 0) { + text += ", "; + } + text += std::to_string(property.intValue[componentIndex]); + } + text += "]"; + return text; + } + case PropertyType::Bool: + return property.boolValue ? "true" : "false"; + case PropertyType::Texture: + case PropertyType::Cubemap: + default: + return std::string(); + } +} + +bool TryResolveMaterialTextureBindingPath( + const ::XCEngine::Resources::Material& material, + const Containers::String& propertyName, + std::string& outPath) { + for (Core::uint32 bindingIndex = 0; bindingIndex < material.GetTextureBindingCount(); ++bindingIndex) { + if (material.GetTextureBindingName(bindingIndex) != propertyName) { + continue; + } + + Containers::String bindingPath = material.GetTextureBindingPath(bindingIndex); + if (bindingPath.Empty()) { + const ::XCEngine::Resources::AssetRef assetRef = material.GetTextureBindingAssetRef(bindingIndex); + if (assetRef.IsValid()) { + ::XCEngine::Resources::ResourceManager::Get().TryResolveAssetPath(assetRef, bindingPath); + } + } + + outPath = std::string(bindingPath.CStr()); + return true; + } + + return false; +} + +MaterialPropertyState BuildMaterialPropertyState( + const ::XCEngine::Resources::MaterialProperty& property, + const ::XCEngine::Resources::Material& material) { + MaterialPropertyState state; + state.name = std::string(property.name.CStr()); + state.type = property.type; + for (size_t index = 0; index < state.floatValue.size(); ++index) { + state.floatValue[index] = property.value.floatValue[index]; + state.intValue[index] = property.value.intValue[index]; + } + state.boolValue = property.value.boolValue; + if (IsTextureMaterialPropertyType(property.type)) { + TryResolveMaterialTextureBindingPath(material, property.name, state.texturePath); + } + return state; +} + +void CopyMaterialPropertyValue( + const MaterialPropertyState& source, + MaterialPropertyState& destination) { + destination.type = source.type; + destination.floatValue = source.floatValue; + destination.intValue = source.intValue; + destination.boolValue = source.boolValue; + destination.texturePath = source.texturePath; + destination.serialized = source.serialized; +} + +std::vector BuildShaderDefaultPropertyStates( + const ::XCEngine::Resources::ResourceHandle<::XCEngine::Resources::Shader>& shaderHandle) { + if (!shaderHandle.IsValid()) { + return {}; + } + + ::XCEngine::Resources::Material scratchMaterial; + scratchMaterial.SetShader(shaderHandle); + return CollectMaterialPropertyStates(scratchMaterial); +} + +constexpr int kCustomRenderQueuePresetIndex = 5; + +int ResolveRenderQueuePresetIndex(int renderQueue) { + switch (renderQueue) { + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Background): + return 0; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Geometry): + return 1; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::AlphaTest): + return 2; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Transparent): + return 3; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Overlay): + return 4; + default: + return kCustomRenderQueuePresetIndex; + } +} + +const char* SerializeRenderQueue(int renderQueue) { + switch (renderQueue) { + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Background): + return "background"; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Geometry): + return "geometry"; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::AlphaTest): + return "alpha_test"; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Transparent): + return "transparent"; + case static_cast(::XCEngine::Resources::MaterialRenderQueue::Overlay): + return "overlay"; + default: + return nullptr; + } +} + +const char* SerializeCullMode(::XCEngine::Resources::MaterialCullMode mode) { + switch (mode) { + case ::XCEngine::Resources::MaterialCullMode::Front: + return "front"; + case ::XCEngine::Resources::MaterialCullMode::Back: + return "back"; + case ::XCEngine::Resources::MaterialCullMode::None: + default: + return "none"; + } +} + +const char* SerializeComparisonFunc(::XCEngine::Resources::MaterialComparisonFunc func) { + switch (func) { + case ::XCEngine::Resources::MaterialComparisonFunc::Never: + return "never"; + case ::XCEngine::Resources::MaterialComparisonFunc::Less: + return "less"; + case ::XCEngine::Resources::MaterialComparisonFunc::Equal: + return "equal"; + case ::XCEngine::Resources::MaterialComparisonFunc::LessEqual: + return "less_equal"; + case ::XCEngine::Resources::MaterialComparisonFunc::Greater: + return "greater"; + case ::XCEngine::Resources::MaterialComparisonFunc::NotEqual: + return "not_equal"; + case ::XCEngine::Resources::MaterialComparisonFunc::GreaterEqual: + return "greater_equal"; + case ::XCEngine::Resources::MaterialComparisonFunc::Always: + default: + return "always"; + } +} + +const char* SerializeBlendOp(::XCEngine::Resources::MaterialBlendOp op) { + switch (op) { + case ::XCEngine::Resources::MaterialBlendOp::Subtract: + return "subtract"; + case ::XCEngine::Resources::MaterialBlendOp::ReverseSubtract: + return "reverse_subtract"; + case ::XCEngine::Resources::MaterialBlendOp::Min: + return "min"; + case ::XCEngine::Resources::MaterialBlendOp::Max: + return "max"; + case ::XCEngine::Resources::MaterialBlendOp::Add: + default: + return "add"; + } +} + +const char* SerializeBlendFactor(::XCEngine::Resources::MaterialBlendFactor factor) { + switch (factor) { + case ::XCEngine::Resources::MaterialBlendFactor::Zero: + return "zero"; + case ::XCEngine::Resources::MaterialBlendFactor::One: + return "one"; + case ::XCEngine::Resources::MaterialBlendFactor::SrcColor: + return "src_color"; + case ::XCEngine::Resources::MaterialBlendFactor::InvSrcColor: + return "one_minus_src_color"; + case ::XCEngine::Resources::MaterialBlendFactor::SrcAlpha: + return "src_alpha"; + case ::XCEngine::Resources::MaterialBlendFactor::InvSrcAlpha: + return "one_minus_src_alpha"; + case ::XCEngine::Resources::MaterialBlendFactor::DstAlpha: + return "dst_alpha"; + case ::XCEngine::Resources::MaterialBlendFactor::InvDstAlpha: + return "one_minus_dst_alpha"; + case ::XCEngine::Resources::MaterialBlendFactor::DstColor: + return "dst_color"; + case ::XCEngine::Resources::MaterialBlendFactor::InvDstColor: + return "one_minus_dst_color"; + case ::XCEngine::Resources::MaterialBlendFactor::SrcAlphaSat: + return "src_alpha_sat"; + case ::XCEngine::Resources::MaterialBlendFactor::BlendFactor: + return "blend_factor"; + case ::XCEngine::Resources::MaterialBlendFactor::InvBlendFactor: + return "one_minus_blend_factor"; + case ::XCEngine::Resources::MaterialBlendFactor::Src1Color: + return "src1_color"; + case ::XCEngine::Resources::MaterialBlendFactor::InvSrc1Color: + return "one_minus_src1_color"; + case ::XCEngine::Resources::MaterialBlendFactor::Src1Alpha: + return "src1_alpha"; + case ::XCEngine::Resources::MaterialBlendFactor::InvSrc1Alpha: + default: + return "one_minus_src1_alpha"; + } +} + +const char* SerializeStencilOp(::XCEngine::Resources::MaterialStencilOp op) { + switch (op) { + case ::XCEngine::Resources::MaterialStencilOp::Zero: + return "zero"; + case ::XCEngine::Resources::MaterialStencilOp::Replace: + return "replace"; + case ::XCEngine::Resources::MaterialStencilOp::IncrSat: + return "incrsat"; + case ::XCEngine::Resources::MaterialStencilOp::DecrSat: + return "decrsat"; + case ::XCEngine::Resources::MaterialStencilOp::Invert: + return "invert"; + case ::XCEngine::Resources::MaterialStencilOp::IncrWrap: + return "incrwrap"; + case ::XCEngine::Resources::MaterialStencilOp::DecrWrap: + return "decrwrap"; + case ::XCEngine::Resources::MaterialStencilOp::Keep: + default: + return "keep"; + } +} + +} // namespace + +bool IsTextureMaterialPropertyType(::XCEngine::Resources::MaterialPropertyType type) { + return type == ::XCEngine::Resources::MaterialPropertyType::Texture || + type == ::XCEngine::Resources::MaterialPropertyType::Cubemap; +} + +std::vector CollectMaterialPropertyStates( + const ::XCEngine::Resources::Material& material) { + std::vector<::XCEngine::Resources::MaterialProperty> properties = material.GetProperties(); + const ::XCEngine::Resources::Shader* shader = material.GetShader(); + if (shader != nullptr && !shader->GetProperties().Empty()) { + std::unordered_map shaderPropertyOrder; + shaderPropertyOrder.reserve(shader->GetProperties().Size()); + for (size_t propertyIndex = 0; propertyIndex < shader->GetProperties().Size(); ++propertyIndex) { + shaderPropertyOrder.emplace(std::string(shader->GetProperties()[propertyIndex].name.CStr()), propertyIndex); + } + + std::sort( + properties.begin(), + properties.end(), + [&shaderPropertyOrder](const auto& left, const auto& right) { + const std::string leftName(left.name.CStr()); + const std::string rightName(right.name.CStr()); + const auto leftIt = shaderPropertyOrder.find(leftName); + const auto rightIt = shaderPropertyOrder.find(rightName); + const size_t leftOrder = leftIt != shaderPropertyOrder.end() ? leftIt->second : shaderPropertyOrder.size(); + const size_t rightOrder = rightIt != shaderPropertyOrder.end() ? rightIt->second : shaderPropertyOrder.size(); + if (leftOrder != rightOrder) { + return leftOrder < rightOrder; + } + return leftName < rightName; + }); + } else { + std::sort( + properties.begin(), + properties.end(), + [](const auto& left, const auto& right) { + return std::strcmp(left.name.CStr(), right.name.CStr()) < 0; + }); + } + + std::vector states; + states.reserve(properties.size()); + for (const ::XCEngine::Resources::MaterialProperty& property : properties) { + states.push_back(BuildMaterialPropertyState(property, material)); + } + return states; +} + +void SyncMaterialAssetStateWithShader( + const ::XCEngine::Resources::ResourceHandle<::XCEngine::Resources::Shader>& shaderHandle, + MaterialAssetState& state) { + if (!shaderHandle.IsValid() || shaderHandle.Get() == nullptr) { + state.keywords.clear(); + state.properties.clear(); + return; + } + + std::unordered_map previousProperties; + previousProperties.reserve(state.properties.size()); + for (const MaterialPropertyState& property : state.properties) { + if (!property.name.empty()) { + previousProperties.emplace(property.name, property); + } + } + + std::vector nextProperties = BuildShaderDefaultPropertyStates(shaderHandle); + for (MaterialPropertyState& property : nextProperties) { + const auto previousPropertyIt = previousProperties.find(property.name); + if (previousPropertyIt == previousProperties.end()) { + continue; + } + + if (previousPropertyIt->second.type != property.type) { + continue; + } + + CopyMaterialPropertyValue(previousPropertyIt->second, property); + } + state.properties = std::move(nextProperties); + + std::vector nextKeywords; + nextKeywords.reserve(state.keywords.size()); + for (const MaterialKeywordState& keyword : state.keywords) { + const std::string keywordValue = TrimCopy(keyword.value); + if (!keywordValue.empty() && shaderHandle->DeclaresKeyword(keywordValue.c_str())) { + nextKeywords.push_back(keyword); + } + } + state.keywords = std::move(nextKeywords); +} + +void ApplyMaterialAuthoringPresenceToState( + const std::string& sourceText, + MaterialAssetState& state) { + const MaterialAuthoringPresence presence = ParseMaterialAuthoringPresence(sourceText); + state.hasRenderStateOverride = presence.hasRenderStateOverride; + + for (MaterialKeywordState& keyword : state.keywords) { + keyword.serialized = presence.keywordValues.find(TrimCopy(keyword.value)) != presence.keywordValues.end(); + } + + for (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(); + } + } +} + +std::string BuildMaterialAssetFileText(const MaterialAssetState& state) { + std::vector rootEntries; + + const std::string shaderPath = TrimCopy(BufferToString(state.shaderPath)); + if (!shaderPath.empty()) { + rootEntries.push_back(" \"shader\": \"" + EscapeJsonString(shaderPath) + "\""); + } + + const int renderQueuePreset = ResolveRenderQueuePresetIndex(state.renderQueue); + if (renderQueuePreset != kCustomRenderQueuePresetIndex) { + rootEntries.push_back( + " \"renderQueue\": \"" + std::string(SerializeRenderQueue(state.renderQueue)) + "\""); + } else { + rootEntries.push_back(" \"renderQueue\": " + std::to_string(state.renderQueue)); + } + + std::vector tagEntries; + for (const MaterialTagEditRow& row : state.tags) { + const std::string tagName = TrimCopy(BufferToString(row.name)); + if (tagName.empty()) { + continue; + } + tagEntries.push_back( + " \"" + EscapeJsonString(tagName) + "\": \"" + + EscapeJsonString(TrimCopy(BufferToString(row.value))) + "\""); + } + if (!tagEntries.empty()) { + std::string tagsObject = " \"tags\": {\n"; + for (size_t tagIndex = 0; tagIndex < tagEntries.size(); ++tagIndex) { + tagsObject += tagEntries[tagIndex]; + if (tagIndex + 1 < tagEntries.size()) { + tagsObject += ","; + } + tagsObject += "\n"; + } + tagsObject += " }"; + rootEntries.push_back(tagsObject); + } + + if (!state.keywords.empty()) { + std::string keywordsArray = " \"keywords\": ["; + bool firstKeyword = true; + for (const MaterialKeywordState& keyword : state.keywords) { + if (!keyword.serialized) { + continue; + } + const std::string keywordValue = TrimCopy(keyword.value); + if (keywordValue.empty()) { + continue; + } + if (!firstKeyword) { + keywordsArray += ", "; + } + keywordsArray += "\"" + EscapeJsonString(keywordValue) + "\""; + firstKeyword = false; + } + keywordsArray += "]"; + if (!firstKeyword) { + rootEntries.push_back(keywordsArray); + } + } + + std::vector propertyEntries; + std::vector textureEntries; + for (const MaterialPropertyState& property : state.properties) { + if (property.name.empty()) { + continue; + } + + if (IsTextureMaterialPropertyType(property.type)) { + if (!property.serialized) { + continue; + } + const std::string texturePath = TrimCopy(property.texturePath); + if (!texturePath.empty()) { + textureEntries.push_back( + " \"" + EscapeJsonString(property.name) + "\": \"" + + EscapeJsonString(texturePath) + "\""); + } + continue; + } + + if (!property.serialized) { + continue; + } + const std::string propertyValueText = BuildMaterialPropertyValueText(property); + if (!propertyValueText.empty()) { + propertyEntries.push_back( + " \"" + EscapeJsonString(property.name) + "\": " + propertyValueText); + } + } + + if (!propertyEntries.empty()) { + std::string propertiesObject = " \"properties\": {\n"; + for (size_t propertyIndex = 0; propertyIndex < propertyEntries.size(); ++propertyIndex) { + propertiesObject += propertyEntries[propertyIndex]; + if (propertyIndex + 1 < propertyEntries.size()) { + propertiesObject += ","; + } + propertiesObject += "\n"; + } + propertiesObject += " }"; + rootEntries.push_back(propertiesObject); + } + + if (!textureEntries.empty()) { + std::string texturesObject = " \"textures\": {\n"; + for (size_t textureIndex = 0; textureIndex < textureEntries.size(); ++textureIndex) { + texturesObject += textureEntries[textureIndex]; + if (textureIndex + 1 < textureEntries.size()) { + texturesObject += ","; + } + texturesObject += "\n"; + } + texturesObject += " }"; + rootEntries.push_back(texturesObject); + } + + if (state.hasRenderStateOverride) { + const ::XCEngine::Resources::MaterialRenderState& renderState = state.renderState; + std::vector renderStateEntries; + renderStateEntries.push_back( + " \"cull\": \"" + std::string(SerializeCullMode(renderState.cullMode)) + "\""); + renderStateEntries.push_back(std::string(" \"depthTest\": ") + (renderState.depthTestEnable ? "true" : "false")); + renderStateEntries.push_back(std::string(" \"depthWrite\": ") + (renderState.depthWriteEnable ? "true" : "false")); + renderStateEntries.push_back( + " \"depthFunc\": \"" + std::string(SerializeComparisonFunc(renderState.depthFunc)) + "\""); + renderStateEntries.push_back(std::string(" \"blendEnable\": ") + (renderState.blendEnable ? "true" : "false")); + renderStateEntries.push_back( + " \"srcBlend\": \"" + std::string(SerializeBlendFactor(renderState.srcBlend)) + "\""); + renderStateEntries.push_back( + " \"dstBlend\": \"" + std::string(SerializeBlendFactor(renderState.dstBlend)) + "\""); + renderStateEntries.push_back( + " \"srcBlendAlpha\": \"" + std::string(SerializeBlendFactor(renderState.srcBlendAlpha)) + "\""); + renderStateEntries.push_back( + " \"dstBlendAlpha\": \"" + std::string(SerializeBlendFactor(renderState.dstBlendAlpha)) + "\""); + renderStateEntries.push_back( + " \"blendOp\": \"" + std::string(SerializeBlendOp(renderState.blendOp)) + "\""); + renderStateEntries.push_back( + " \"blendOpAlpha\": \"" + std::string(SerializeBlendOp(renderState.blendOpAlpha)) + "\""); + renderStateEntries.push_back(" \"colorWriteMask\": " + std::to_string(renderState.colorWriteMask)); + if (renderState.depthBiasFactor != 0.0f || renderState.depthBiasUnits != 0) { + renderStateEntries.push_back( + " \"offset\": [" + + FormatJsonFloat(renderState.depthBiasFactor) + ", " + + std::to_string(renderState.depthBiasUnits) + "]"); + } + if (renderState.stencil.enabled) { + std::vector stencilEntries; + stencilEntries.push_back(" \"enabled\": true"); + stencilEntries.push_back(" \"ref\": " + std::to_string(renderState.stencil.reference)); + stencilEntries.push_back(" \"readMask\": " + std::to_string(renderState.stencil.readMask)); + stencilEntries.push_back(" \"writeMask\": " + std::to_string(renderState.stencil.writeMask)); + stencilEntries.push_back( + " \"comp\": \"" + std::string(SerializeComparisonFunc(renderState.stencil.front.func)) + "\""); + stencilEntries.push_back( + " \"pass\": \"" + std::string(SerializeStencilOp(renderState.stencil.front.passOp)) + "\""); + stencilEntries.push_back( + " \"fail\": \"" + std::string(SerializeStencilOp(renderState.stencil.front.failOp)) + "\""); + stencilEntries.push_back( + " \"zFail\": \"" + std::string(SerializeStencilOp(renderState.stencil.front.depthFailOp)) + "\""); + stencilEntries.push_back( + " \"compBack\": \"" + std::string(SerializeComparisonFunc(renderState.stencil.back.func)) + "\""); + stencilEntries.push_back( + " \"passBack\": \"" + std::string(SerializeStencilOp(renderState.stencil.back.passOp)) + "\""); + stencilEntries.push_back( + " \"failBack\": \"" + std::string(SerializeStencilOp(renderState.stencil.back.failOp)) + "\""); + stencilEntries.push_back( + " \"zFailBack\": \"" + std::string(SerializeStencilOp(renderState.stencil.back.depthFailOp)) + "\""); + + std::string stencilObject = " \"stencil\": {\n"; + for (size_t stencilIndex = 0; stencilIndex < stencilEntries.size(); ++stencilIndex) { + stencilObject += stencilEntries[stencilIndex]; + if (stencilIndex + 1 < stencilEntries.size()) { + stencilObject += ","; + } + stencilObject += "\n"; + } + stencilObject += " }"; + renderStateEntries.push_back(stencilObject); + } + + std::string renderStateObject = " \"renderState\": {\n"; + for (size_t renderStateIndex = 0; renderStateIndex < renderStateEntries.size(); ++renderStateIndex) { + renderStateObject += renderStateEntries[renderStateIndex]; + if (renderStateIndex + 1 < renderStateEntries.size()) { + renderStateObject += ","; + } + renderStateObject += "\n"; + } + renderStateObject += " }"; + rootEntries.push_back(renderStateObject); + } + + std::string json = "{\n"; + for (size_t entryIndex = 0; entryIndex < rootEntries.size(); ++entryIndex) { + json += rootEntries[entryIndex]; + if (entryIndex + 1 < rootEntries.size()) { + json += ","; + } + json += "\n"; + } + json += "}\n"; + return json; +} + +} // namespace Editor +} // namespace XCEngine diff --git a/editor/src/panels/MaterialInspectorMaterialStateIO.h b/editor/src/panels/MaterialInspectorMaterialStateIO.h new file mode 100644 index 00000000..1c78d6e1 --- /dev/null +++ b/editor/src/panels/MaterialInspectorMaterialStateIO.h @@ -0,0 +1,34 @@ +#pragma once + +#include "MaterialInspectorMaterialState.h" + +#include + +#include +#include + +namespace XCEngine { +namespace Resources { +class Material; +class Shader; +} + +namespace Editor { + +bool IsTextureMaterialPropertyType(::XCEngine::Resources::MaterialPropertyType type); + +std::vector CollectMaterialPropertyStates( + const ::XCEngine::Resources::Material& material); + +void SyncMaterialAssetStateWithShader( + const ::XCEngine::Resources::ResourceHandle<::XCEngine::Resources::Shader>& shaderHandle, + MaterialAssetState& state); + +void ApplyMaterialAuthoringPresenceToState( + const std::string& sourceText, + MaterialAssetState& state); + +std::string BuildMaterialAssetFileText(const MaterialAssetState& state); + +} // namespace Editor +} // namespace XCEngine diff --git a/tests/editor/CMakeLists.txt b/tests/editor/CMakeLists.txt index 1cd6d545..eab69722 100644 --- a/tests/editor/CMakeLists.txt +++ b/tests/editor/CMakeLists.txt @@ -24,6 +24,7 @@ set(EDITOR_TEST_SOURCES test_scene_viewport_overlay_renderer.cpp test_scene_viewport_overlay_providers.cpp test_script_component_editor_utils.cpp + test_viewport_panel_content.cpp test_viewport_host_surface_utils.cpp test_viewport_object_id_picker.cpp test_viewport_render_targets.cpp @@ -36,6 +37,7 @@ set(EDITOR_TEST_SOURCES test_editor_console_sink.cpp test_editor_script_assembly_builder.cpp test_editor_script_assembly_builder_utils.cpp + test_material_inspector_material_state_io.cpp ${CMAKE_BINARY_DIR}/_deps/imgui-src/imgui.cpp ${CMAKE_BINARY_DIR}/_deps/imgui-src/imgui_draw.cpp ${CMAKE_BINARY_DIR}/_deps/imgui-src/imgui_tables.cpp @@ -46,6 +48,8 @@ set(EDITOR_TEST_SOURCES ${CMAKE_SOURCE_DIR}/editor/src/Core/PlaySessionController.cpp ${CMAKE_SOURCE_DIR}/editor/src/Managers/SceneManager.cpp ${CMAKE_SOURCE_DIR}/editor/src/Managers/ProjectManager.cpp + ${CMAKE_SOURCE_DIR}/editor/src/panels/MaterialInspectorMaterialStateIO.cpp + ${CMAKE_SOURCE_DIR}/editor/src/UI/BuiltInIcons.cpp ${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportPicker.cpp ${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportMoveGizmo.cpp ${CMAKE_SOURCE_DIR}/editor/src/Viewport/SceneViewportRotateGizmo.cpp diff --git a/tests/editor/test_material_inspector_material_state_io.cpp b/tests/editor/test_material_inspector_material_state_io.cpp new file mode 100644 index 00000000..89ae422f --- /dev/null +++ b/tests/editor/test_material_inspector_material_state_io.cpp @@ -0,0 +1,308 @@ +#include + +#include "panels/MaterialInspectorMaterialState.h" +#include "panels/MaterialInspectorMaterialStateIO.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +using XCEngine::Editor::ApplyMaterialAuthoringPresenceToState; +using XCEngine::Editor::BuildMaterialAssetFileText; +using XCEngine::Editor::MaterialAssetState; +using XCEngine::Editor::MaterialKeywordState; +using XCEngine::Editor::MaterialPropertyState; +using XCEngine::Editor::MaterialTagEditRow; +using XCEngine::Editor::SyncMaterialAssetStateWithShader; +using XCEngine::Resources::IResource; +using XCEngine::Resources::MaterialBlendFactor; +using XCEngine::Resources::MaterialBlendOp; +using XCEngine::Resources::MaterialComparisonFunc; +using XCEngine::Resources::MaterialCullMode; +using XCEngine::Resources::MaterialPropertyType; +using XCEngine::Resources::MaterialRenderQueue; +using XCEngine::Resources::ResourceGUID; +using XCEngine::Resources::ResourceHandle; +using XCEngine::Resources::Shader; +using XCEngine::Resources::ShaderKeywordDeclaration; +using XCEngine::Resources::ShaderKeywordDeclarationType; +using XCEngine::Resources::ShaderPass; +using XCEngine::Resources::ShaderPropertyDesc; +using XCEngine::Resources::ShaderPropertyType; + +template +void CopyText(const std::string& value, std::array& buffer) { + buffer.fill('\0'); + const size_t copyLength = (std::min)(value.size(), N - 1); + if (copyLength > 0) { + std::memcpy(buffer.data(), value.data(), copyLength); + } + buffer[copyLength] = '\0'; +} + +MaterialPropertyState* FindProperty(MaterialAssetState& state, const char* name) { + for (MaterialPropertyState& property : state.properties) { + if (property.name == name) { + return &property; + } + } + return nullptr; +} + +std::unique_ptr CreateSchemaShader( + const char* path, + std::initializer_list declaredKeywords = {}) { + auto shader = std::make_unique(); + + IResource::ConstructParams params = {}; + params.name = "InspectorTestShader"; + params.path = path; + params.guid = ResourceGUID::Generate(path); + shader->Initialize(params); + + ShaderPropertyDesc baseColor = {}; + baseColor.name = "_BaseColor"; + baseColor.displayName = "Base Color"; + baseColor.type = ShaderPropertyType::Color; + baseColor.defaultValue = "(1,1,1,1)"; + shader->AddProperty(baseColor); + + ShaderPropertyDesc metallic = {}; + metallic.name = "_Metallic"; + metallic.displayName = "Metallic"; + metallic.type = ShaderPropertyType::Float; + metallic.defaultValue = "0.7"; + shader->AddProperty(metallic); + + ShaderPropertyDesc mode = {}; + mode.name = "_Mode"; + mode.displayName = "Mode"; + mode.type = ShaderPropertyType::Int; + mode.defaultValue = "2"; + shader->AddProperty(mode); + + ShaderPropertyDesc mainTex = {}; + mainTex.name = "_MainTex"; + mainTex.displayName = "Main Tex"; + mainTex.type = ShaderPropertyType::Texture2D; + mainTex.defaultValue = "white"; + shader->AddProperty(mainTex); + + ShaderPass pass = {}; + pass.name = "ForwardLit"; + shader->AddPass(pass); + + if (declaredKeywords.size() > 0) { + ShaderKeywordDeclaration declaration = {}; + declaration.type = ShaderKeywordDeclarationType::ShaderFeatureLocal; + declaration.options.PushBack("_"); + for (const char* keyword : declaredKeywords) { + declaration.options.PushBack(keyword); + } + shader->AddPassKeywordDeclaration("ForwardLit", declaration); + } + + return shader; +} + +TEST(MaterialInspectorMaterialStateIOTest, ApplyMaterialAuthoringPresenceMarksOnlySourceAuthoredEntries) { + MaterialAssetState state; + state.keywords = { + MaterialKeywordState{ "XC_ALPHA_TEST", false }, + MaterialKeywordState{ "XC_UNUSED", false } + }; + + MaterialPropertyState metallic = {}; + metallic.name = "_Metallic"; + metallic.type = MaterialPropertyType::Float; + state.properties.push_back(metallic); + + MaterialPropertyState baseColor = {}; + baseColor.name = "_BaseColor"; + baseColor.type = MaterialPropertyType::Float4; + state.properties.push_back(baseColor); + + MaterialPropertyState mainTex = {}; + mainTex.name = "_MainTex"; + mainTex.type = MaterialPropertyType::Texture; + state.properties.push_back(mainTex); + + ApplyMaterialAuthoringPresenceToState( + "{\n" + " \"keywords\": [\"XC_ALPHA_TEST\"],\n" + " \"properties\": { \"_Metallic\": 0.35 },\n" + " \"textures\": { \"_MainTex\": \"Assets/Textures/albedo.png\" }\n" + "}\n", + state); + + EXPECT_FALSE(state.hasRenderStateOverride); + ASSERT_EQ(state.keywords.size(), 2u); + EXPECT_TRUE(state.keywords[0].serialized); + EXPECT_FALSE(state.keywords[1].serialized); + + ASSERT_NE(FindProperty(state, "_Metallic"), nullptr); + ASSERT_NE(FindProperty(state, "_BaseColor"), nullptr); + ASSERT_NE(FindProperty(state, "_MainTex"), nullptr); + EXPECT_TRUE(FindProperty(state, "_Metallic")->serialized); + EXPECT_FALSE(FindProperty(state, "_BaseColor")->serialized); + EXPECT_TRUE(FindProperty(state, "_MainTex")->serialized); +} + +TEST(MaterialInspectorMaterialStateIOTest, SyncMaterialAssetStateWithShaderPreservesCompatibleOverridesAndDropsStaleData) { + auto shader = CreateSchemaShader( + "memory://material_inspector_sync_shader", + { "XC_ALPHA_TEST" }); + ResourceHandle shaderHandle(shader.get()); + + MaterialAssetState state; + + MaterialPropertyState legacy = {}; + legacy.name = "_Legacy"; + legacy.type = MaterialPropertyType::Float; + legacy.floatValue[0] = 9.0f; + legacy.serialized = true; + state.properties.push_back(legacy); + + MaterialPropertyState metallic = {}; + metallic.name = "_Metallic"; + metallic.type = MaterialPropertyType::Float; + metallic.floatValue[0] = 0.33f; + metallic.serialized = true; + state.properties.push_back(metallic); + + MaterialPropertyState mainTex = {}; + mainTex.name = "_MainTex"; + mainTex.type = MaterialPropertyType::Texture; + mainTex.texturePath = "Assets/Textures/albedo.png"; + mainTex.serialized = true; + state.properties.push_back(mainTex); + + state.keywords.push_back(MaterialKeywordState{ "XC_ALPHA_TEST", true }); + state.keywords.push_back(MaterialKeywordState{ "XC_OLD", true }); + + SyncMaterialAssetStateWithShader(shaderHandle, state); + + std::vector propertyNames; + propertyNames.reserve(state.properties.size()); + for (const MaterialPropertyState& property : state.properties) { + propertyNames.push_back(property.name); + } + + const std::vector expectedNames = { + "_BaseColor", + "_Metallic", + "_Mode", + "_MainTex" + }; + EXPECT_EQ(propertyNames, expectedNames); + + ASSERT_NE(FindProperty(state, "_Metallic"), nullptr); + ASSERT_NE(FindProperty(state, "_MainTex"), nullptr); + EXPECT_FLOAT_EQ(FindProperty(state, "_Metallic")->floatValue[0], 0.33f); + EXPECT_TRUE(FindProperty(state, "_Metallic")->serialized); + EXPECT_EQ(FindProperty(state, "_MainTex")->texturePath, "Assets/Textures/albedo.png"); + EXPECT_TRUE(FindProperty(state, "_MainTex")->serialized); + EXPECT_EQ(FindProperty(state, "_Mode")->intValue[0], 2); + EXPECT_EQ(FindProperty(state, "_Legacy"), nullptr); + + ASSERT_EQ(state.keywords.size(), 1u); + EXPECT_EQ(state.keywords[0].value, "XC_ALPHA_TEST"); + EXPECT_TRUE(state.keywords[0].serialized); +} + +TEST(MaterialInspectorMaterialStateIOTest, BuildMaterialAssetFileTextOmitsNonSerializedDefaultsAndDisabledRenderState) { + MaterialAssetState state; + CopyText("Assets/Shaders/test.shader", state.shaderPath); + state.renderQueue = static_cast(MaterialRenderQueue::Geometry); + + MaterialTagEditRow tag = {}; + CopyText("RenderType", tag.name); + CopyText("Opaque", tag.value); + state.tags.push_back(tag); + + state.keywords.push_back(MaterialKeywordState{ "XC_ALPHA_TEST", true }); + state.keywords.push_back(MaterialKeywordState{ "XC_UNUSED", false }); + + MaterialPropertyState metallic = {}; + metallic.name = "_Metallic"; + metallic.type = MaterialPropertyType::Float; + metallic.floatValue[0] = 0.25f; + metallic.serialized = true; + state.properties.push_back(metallic); + + MaterialPropertyState baseColor = {}; + baseColor.name = "_BaseColor"; + baseColor.type = MaterialPropertyType::Float4; + baseColor.floatValue = { 1.0f, 0.2f, 0.1f, 1.0f }; + baseColor.serialized = false; + state.properties.push_back(baseColor); + + MaterialPropertyState mainTex = {}; + mainTex.name = "_MainTex"; + mainTex.type = MaterialPropertyType::Texture; + mainTex.texturePath = "Assets/Textures/albedo.png"; + mainTex.serialized = false; + state.properties.push_back(mainTex); + + const std::string text = BuildMaterialAssetFileText(state); + + EXPECT_NE(text.find("\"shader\": \"Assets/Shaders/test.shader\""), std::string::npos); + EXPECT_NE(text.find("\"renderQueue\": \"geometry\""), std::string::npos); + EXPECT_NE(text.find("\"RenderType\": \"Opaque\""), std::string::npos); + EXPECT_NE(text.find("\"keywords\": [\"XC_ALPHA_TEST\"]"), std::string::npos); + EXPECT_NE(text.find("\"_Metallic\": 0.25"), std::string::npos); + EXPECT_EQ(text.find("\"_BaseColor\""), std::string::npos); + EXPECT_EQ(text.find("\"textures\""), std::string::npos); + EXPECT_EQ(text.find("\"renderState\""), std::string::npos); +} + +TEST(MaterialInspectorMaterialStateIOTest, BuildMaterialAssetFileTextWritesSerializedTextureAndRenderStateOverrides) { + MaterialAssetState state; + CopyText("Assets/Shaders/test.shader", state.shaderPath); + state.renderQueue = 2051; + state.hasRenderStateOverride = true; + state.renderState.cullMode = MaterialCullMode::Back; + state.renderState.depthTestEnable = true; + state.renderState.depthWriteEnable = false; + state.renderState.depthFunc = MaterialComparisonFunc::LessEqual; + state.renderState.blendEnable = true; + state.renderState.srcBlend = MaterialBlendFactor::SrcAlpha; + state.renderState.dstBlend = MaterialBlendFactor::InvSrcAlpha; + state.renderState.srcBlendAlpha = MaterialBlendFactor::One; + state.renderState.dstBlendAlpha = MaterialBlendFactor::InvSrcAlpha; + state.renderState.blendOp = MaterialBlendOp::Add; + state.renderState.blendOpAlpha = MaterialBlendOp::Add; + state.renderState.colorWriteMask = 7; + + state.keywords.push_back(MaterialKeywordState{ "XC_ALPHA_TEST", true }); + + MaterialPropertyState mainTex = {}; + mainTex.name = "_MainTex"; + mainTex.type = MaterialPropertyType::Texture; + mainTex.texturePath = "Assets/Textures/albedo.png"; + mainTex.serialized = true; + state.properties.push_back(mainTex); + + const std::string text = BuildMaterialAssetFileText(state); + + EXPECT_NE(text.find("\"renderQueue\": 2051"), std::string::npos); + EXPECT_NE(text.find("\"keywords\": [\"XC_ALPHA_TEST\"]"), std::string::npos); + EXPECT_NE(text.find("\"textures\": {"), std::string::npos); + EXPECT_NE(text.find("\"_MainTex\": \"Assets/Textures/albedo.png\""), std::string::npos); + EXPECT_NE(text.find("\"renderState\": {"), std::string::npos); + EXPECT_NE(text.find("\"cull\": \"back\""), std::string::npos); + EXPECT_NE(text.find("\"blendEnable\": true"), std::string::npos); + EXPECT_NE(text.find("\"srcBlend\": \"src_alpha\""), std::string::npos); + EXPECT_NE(text.find("\"colorWriteMask\": 7"), std::string::npos); +} + +} // namespace diff --git a/tests/editor/test_viewport_panel_content.cpp b/tests/editor/test_viewport_panel_content.cpp new file mode 100644 index 00000000..48fc3e2a --- /dev/null +++ b/tests/editor/test_viewport_panel_content.cpp @@ -0,0 +1,103 @@ +#include + +#include "panels/ViewportPanelContent.h" + +#include + +namespace { + +class ImGuiContextScope { +public: + ImGuiContextScope() { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + } + + ~ImGuiContextScope() { + ImGui::DestroyContext(); + } +}; + +void BeginTestFrame() { + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(640.0f, 480.0f); + io.DeltaTime = 1.0f / 60.0f; + unsigned char* fontPixels = nullptr; + int fontWidth = 0; + int fontHeight = 0; + io.Fonts->GetTexDataAsRGBA32(&fontPixels, &fontWidth, &fontHeight); + io.Fonts->SetTexID(static_cast(1)); + ImGui::GetStyle().WindowPadding = ImVec2(0.0f, 0.0f); + + ImGui::NewFrame(); + ImGui::SetNextWindowPos(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(320.0f, 240.0f)); + ASSERT_TRUE(ImGui::Begin( + "ViewportPanelContentTestWindow", + nullptr, + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoSavedSettings)); +} + +void EndTestFrame() { + ImGui::End(); + ImGui::EndFrame(); +} + +} // namespace + +TEST(ViewportPanelContentTest, AllowOverlapDefaultItemClickMissesMiddleClickOnFirstFrame) { + ImGuiContextScope contextScope; + ImGuiIO& io = ImGui::GetIO(); + + BeginTestFrame(); + EndTestFrame(); + + io.MousePos = ImVec2(32.0f, 32.0f); + io.MouseDown[ImGuiMouseButton_Middle] = true; + BeginTestFrame(); + + ImGui::SetCursorScreenPos(ImVec2(16.0f, 16.0f)); + + ImGui::SetNextItemAllowOverlap(); + ImGui::InvisibleButton( + "##ViewportOverlapProbe", + ImVec2(120.0f, 90.0f), + ImGuiButtonFlags_MouseButtonMiddle); + + EXPECT_FALSE(ImGui::IsItemHovered()); + EXPECT_FALSE(ImGui::IsItemClicked(ImGuiMouseButton_Middle)); + EXPECT_TRUE(ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenOverlappedByItem)); + + EndTestFrame(); +} + +TEST(ViewportPanelContentTest, InteractionSurfaceCapturesMiddleClickOnFirstHoveredFrame) { + ImGuiContextScope contextScope; + ImGuiIO& io = ImGui::GetIO(); + + BeginTestFrame(); + EndTestFrame(); + + io.MousePos = ImVec2(32.0f, 32.0f); + io.MouseDown[ImGuiMouseButton_Middle] = true; + BeginTestFrame(); + + ImGui::SetCursorScreenPos(ImVec2(16.0f, 16.0f)); + + XCEngine::Editor::ViewportPanelContentResult result = {}; + XCEngine::Editor::RenderViewportInteractionSurface( + result, + XCEngine::Editor::EditorViewportKind::Scene, + ImVec2(120.0f, 90.0f)); + + EXPECT_TRUE(result.hovered); + EXPECT_TRUE(result.clickedMiddle); + EXPECT_FALSE(result.clickedLeft); + EXPECT_FALSE(result.clickedRight); + + EndTestFrame(); +}