editor: preserve material authoring overrides

This commit is contained in:
2026-04-07 21:47:36 +08:00
parent 4c26b410cb
commit 6289777c8e
3 changed files with 348 additions and 3 deletions

View File

@@ -481,3 +481,44 @@ Material 面板不应再次演化成随意堆字段的临时入口。所有 rend
- 针对 Inspector 材质链路的专门自动化测试 - 针对 Inspector 材质链路的专门自动化测试
因此Phase 3 的完成标准是“Shader schema 驱动的属性面板已经建立起来”,但还不是最终形态。 因此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 语义做正确”,为最后的测试与收口创造条件。

View File

@@ -21,6 +21,7 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <cctype>
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -28,6 +29,7 @@
#include <cstring> #include <cstring>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <unordered_set>
namespace XCEngine { namespace XCEngine {
namespace Editor { namespace Editor {
@@ -105,6 +107,254 @@ std::string EscapeJsonString(const std::string& value) {
return escaped; return escaped;
} }
size_t SkipWhitespace(const std::string& text, size_t position) {
while (position < text.size() && std::isspace(static_cast<unsigned char>(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<std::string>& 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<std::string>& 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<std::string> keywordValues;
std::unordered_set<std::string> propertyKeys;
std::unordered_set<std::string> 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<char>(input)),
std::istreambuf_iterator<char>());
}
struct InspectorAssetReferenceInteraction { struct InspectorAssetReferenceInteraction {
std::string assignedPath; std::string assignedPath;
bool clearRequested = false; bool clearRequested = false;
@@ -750,6 +1000,7 @@ void CopyMaterialPropertyValue(
destination.intValue = source.intValue; destination.intValue = source.intValue;
destination.boolValue = source.boolValue; destination.boolValue = source.boolValue;
destination.texturePath = source.texturePath; destination.texturePath = source.texturePath;
destination.serialized = source.serialized;
} }
std::vector<InspectorPanel::MaterialPropertyState> BuildShaderDefaultPropertyStates( std::vector<InspectorPanel::MaterialPropertyState> BuildShaderDefaultPropertyStates(
@@ -807,6 +1058,24 @@ void SyncMaterialAssetStateWithShader(
state.keywords = std::move(nextKeywords); 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::MaterialPropertyState* FindMaterialPropertyState(
InspectorPanel::MaterialAssetState& state, InspectorPanel::MaterialAssetState& state,
const Containers::String& propertyName) { const Containers::String& propertyName) {
@@ -846,7 +1115,11 @@ bool DrawShaderDrivenMaterialProperty(
switch (shaderProperty.type) { switch (shaderProperty.type) {
case ::XCEngine::Resources::ShaderPropertyType::Float: case ::XCEngine::Resources::ShaderPropertyType::Float:
case ::XCEngine::Resources::ShaderPropertyType::Range: 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: { case ::XCEngine::Resources::ShaderPropertyType::Int: {
int value = propertyState->intValue[0]; int value = propertyState->intValue[0];
@@ -854,11 +1127,16 @@ bool DrawShaderDrivenMaterialProperty(
return false; return false;
} }
propertyState->intValue[0] = value; propertyState->intValue[0] = value;
propertyState->serialized = true;
return true; return true;
} }
case ::XCEngine::Resources::ShaderPropertyType::Color: 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: { case ::XCEngine::Resources::ShaderPropertyType::Vector: {
if (propertyState->type == ::XCEngine::Resources::MaterialPropertyType::Float2) { if (propertyState->type == ::XCEngine::Resources::MaterialPropertyType::Float2) {
@@ -868,6 +1146,7 @@ bool DrawShaderDrivenMaterialProperty(
} }
propertyState->floatValue[0] = value.x; propertyState->floatValue[0] = value.x;
propertyState->floatValue[1] = value.y; propertyState->floatValue[1] = value.y;
propertyState->serialized = true;
return true; return true;
} }
if (propertyState->type == ::XCEngine::Resources::MaterialPropertyType::Float3) { if (propertyState->type == ::XCEngine::Resources::MaterialPropertyType::Float3) {
@@ -881,9 +1160,14 @@ bool DrawShaderDrivenMaterialProperty(
propertyState->floatValue[0] = value.x; propertyState->floatValue[0] = value.x;
propertyState->floatValue[1] = value.y; propertyState->floatValue[1] = value.y;
propertyState->floatValue[2] = value.z; propertyState->floatValue[2] = value.z;
propertyState->serialized = true;
return 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: case ::XCEngine::Resources::ShaderPropertyType::Texture2D:
@@ -899,11 +1183,13 @@ bool DrawShaderDrivenMaterialProperty(
return false; return false;
} }
propertyState->texturePath.clear(); propertyState->texturePath.clear();
propertyState->serialized = false;
return true; return true;
} }
if (!textureInteraction.assignedPath.empty() && if (!textureInteraction.assignedPath.empty() &&
textureInteraction.assignedPath != propertyState->texturePath) { textureInteraction.assignedPath != propertyState->texturePath) {
propertyState->texturePath = textureInteraction.assignedPath; propertyState->texturePath = textureInteraction.assignedPath;
propertyState->serialized = true;
return true; return true;
} }
return false; return false;
@@ -980,6 +1266,9 @@ std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState&
std::string keywordsArray = " \"keywords\": ["; std::string keywordsArray = " \"keywords\": [";
bool firstKeyword = true; bool firstKeyword = true;
for (const InspectorPanel::MaterialKeywordState& keyword : state.keywords) { for (const InspectorPanel::MaterialKeywordState& keyword : state.keywords) {
if (!keyword.serialized) {
continue;
}
const std::string keywordValue = TrimCopy(keyword.value); const std::string keywordValue = TrimCopy(keyword.value);
if (keywordValue.empty()) { if (keywordValue.empty()) {
continue; continue;
@@ -1004,6 +1293,9 @@ std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState&
} }
if (IsTextureMaterialPropertyType(property.type)) { if (IsTextureMaterialPropertyType(property.type)) {
if (!property.serialized) {
continue;
}
const std::string texturePath = TrimCopy(property.texturePath); const std::string texturePath = TrimCopy(property.texturePath);
if (!texturePath.empty()) { if (!texturePath.empty()) {
textureEntries.push_back( textureEntries.push_back(
@@ -1013,6 +1305,9 @@ std::string BuildMaterialAssetFileText(const InspectorPanel::MaterialAssetState&
continue; continue;
} }
if (!property.serialized) {
continue;
}
const std::string propertyValueText = BuildMaterialPropertyValueText(property); const std::string propertyValueText = BuildMaterialPropertyValueText(property);
if (!propertyValueText.empty()) { if (!propertyValueText.empty()) {
propertyEntries.push_back( propertyEntries.push_back(
@@ -1228,6 +1523,13 @@ void InspectorPanel::PopulateMaterialAssetStateFromResource(::XCEngine::Resource
m_materialAssetState.properties = CollectMaterialPropertyStates(material); 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.loaded = true;
m_materialAssetState.dirty = false; m_materialAssetState.dirty = false;
} }

View File

@@ -45,6 +45,7 @@ public:
struct MaterialKeywordState { struct MaterialKeywordState {
std::string value; std::string value;
bool serialized = false;
}; };
struct MaterialPropertyState { struct MaterialPropertyState {
@@ -55,6 +56,7 @@ public:
std::array<::XCEngine::Core::int32, 4> intValue{}; std::array<::XCEngine::Core::int32, 4> intValue{};
bool boolValue = false; bool boolValue = false;
std::string texturePath; std::string texturePath;
bool serialized = false;
}; };
struct MaterialAssetState { struct MaterialAssetState {