editor: preserve material authoring overrides
This commit is contained in:
@@ -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 语义做正确”,为最后的测试与收口创造条件。
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -28,6 +29,7 @@
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
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<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 {
|
||||
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<InspectorPanel::MaterialPropertyState> 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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user