前言

  最近遇到一个需求,需要做一个 Unreal 材质实例属性传递工具。
  Unreal 有内置批量属性编辑工具,但是似乎对无法实现材质实例属性的批量修改。

alt
alt

  于是我利用 Python Qt 开发一个可以批量传递材质属性的工具。

alt

  上面的界面结合了之前研究的 Python Qt Overlay 堆叠组件 ,让界面维护更方便,UI 排列更紧凑。

基于材质编辑器实例的材质树获取

  制作这个工具需要解决的问题是如何从材质里面知道材质的分组,分组从而构建出树形结构。


  最初我是想通过材质编辑器上面的参数组来获取这个关系,为此还研究了很多 MaterialEditorInstance 相关的东西。
  但是这玩意就是个巨坑,研究到最后发现修改的是 UI 的数据,没法改到材质属性,还是得借助 MaterialEditingLibrary 库对材质修改属性才可行(:з」∠)


  基于上一篇文章研究 Unreal Python 利用 UtilityWidget 编写 UMG 工具界面 可以通过 C++ 罗列出所有的 UObject
  通过上面的黑科技结合以前 list_menu 的方法,我可以获取到所有的 MaterialEditorInstanceConstant 类型

1
2
3
4
5
6
7
def list_material_editor(num=1000):
return [
unreal.load_object(
None, "/Engine/Transient.MaterialEditorInstanceConstant_%s" % i
)
for i in range(num)
]

  然而这个获取到的并非是当前打开的编辑窗口。
  有可能是已经关闭的老窗口。
  因此需要用 C++ 获取当前正在编辑的资产,然后再获取 MaterialEditorInstanceConstant 里面所属的材质
  判断哪个是最新的材质面板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

UMaterialInstanceConstant *UPyToolkitBPLibrary::GetMaterialEditorSourceInstance(UMaterialEditorInstanceConstant *Editor)
{
return Editor->SourceInstance;
}

UObject *UPyToolkitBPLibrary::GetFocusedEditAsset()
{
UAssetEditorSubsystem *sub = GEditor->GetEditorSubsystem<UAssetEditorSubsystem>();

UObject *FocusObject = nullptr;
double maxLastActivationTime = 0.0;

for (UObject *EditedAsset : sub->GetAllEditedAssets())
{
auto openedEditor = sub->FindEditorForAsset(EditedAsset, false);

if (openedEditor && openedEditor->GetLastActivationTime() > maxLastActivationTime)
{
maxLastActivationTime = openedEditor->GetLastActivationTime();
FocusObject = EditedAsset;
}
}

return FocusObject;
}

  结合上面两个 C++ 就可以获取到当前正在编辑的 材质编辑器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
py_lib = unreal.PyToolkitBPLibrary()
def list_material_editor(num=1000):
material_editor = {}
for i in range(num):
editor = unreal.load_object(None,"/Engine/Transient.MaterialEditorInstanceConstant_%s" % i)
if editor:
material = py_lib.get_material_editor_source_instance(editor)
if material:
material_editor[material] = editor
return material_editor

edit_asset = py_lib.get_focused_edit_asset()
material_editor = list_material_editor()
editor = material_editor.get(edit_asset)

  通过 遍历 objects 的方式可以获取材质编辑器下的 DEditorParameterValue

alt

  通过这个方法可以直接修改 MaterialEditor UI 下的选项。

1
2
3
4
5
6
7
8
9
py_lib = unreal.PyToolkitBPLibrary()
param = unreal.load_object(None,"/Engine/Transient.DEditorScalarParameterValue_0")
print(py_lib.get_all_properties(param))
# ["ParameterValue", "bOverride", "ParameterInfo"]
info = param.get_editor_property("ParameterInfo")
print(info.name)
# Input Pattern Light
param.set_editor_property("bOverride",True)
param.set_editor_property("ParameterValue",0.5)

alt

  可以用我以前写的 get_all_properties C++ 函数获取 UObject 内置的 property
  当然直接查 C++ 源码也可以查到 UProperty ,只有 EditAnywhere 的属性可以被 Python 修改。

  然而这个只是 UI 层面的修改,并没有修改到 材质 内置的属性数据。
  当我关闭编辑器重新打开,数据又回滚到之前的状态了。
  所以折腾了一圈放弃了这个方案。

基于母材质数据的获取

  上面的方案需要打开材质编辑器,而且也无法通过修改 UI 来改到材质实例的数据。
  于是我深挖了引擎的 C++ 的代码。(花了我很多时间(:з」∠))
  最后发现这个材质组是通过 母材质 的 MaterialExpression 来解析分组的。

alt

  有了这个之后,我也可以用类似的方法用 Python 调用实现材质属性树的构建。
  然而 Python 无法直接获取材质的 Expressions , 需要使用 C++ 包装一个函数进行获取。

1
2
3
4
5
6
7
8
9
TArray<UMaterialExpression *> UPyToolkitBPLibrary::GetMaterialExpressions(UMaterial *Material)
{
return Material->Expressions;
}

TArray<UMaterialExpression *> UPyToolkitBPLibrary::GetMaterialFunctionExpressions(UMaterialFunction *Function)
{
return Function->FunctionExpressions;
}

  添加上面两个 C++ 函数进行 Python 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import json
from collections import defaultdict
py_lib = unreal.PyToolkitBPLibrary
mat_lib = unreal.MaterialEditingLibrary

def cast(typ, obj):
"""
unreal cast 类型转换
"""
try:
return getattr(unreal, typ).cast(obj)
except:
return None

def _get_material_paramters(expressions):
"""
inspire by https://github.com/20tab/UnrealEnginePython/issues/103
reference from MaterialFunctionInterface.h `GetParameterGroupName`
"""
paramters = defaultdict(set)
for expresion in expressions:
# NOTE 查找 material function 内部节点
func = cast("MaterialExpressionMaterialFunctionCall", expresion)
if func:
func = func.get_editor_property("material_function")
expressions = py_lib.get_material_function_expressions(func)
# NOTE 递归查找参数节点
params = _get_material_paramters(expressions)
for group, param in params.items():
for p in param:
paramters[str(group)].add(str(p))
continue

# NOTE 查找转换参数节点
param = cast("MaterialExpressionParameter", expresion)
if not param:
param = cast("MaterialExpressionTextureSampleParameter", expresion)
if not param:
param = cast("MaterialExpressionFontSampleParameter", expresion)

# NOTE 查找参数节点的 分组 和 参数命名
if param:
group = param.get_editor_property("group")
parameter_name = param.get_editor_property("parameter_name")
paramters[str(group)].add(str(parameter_name))

return paramters

def get_material_paramters(material):
# NOTE 获取材质属性的命名
scalars = mat_lib.get_scalar_parameter_names(material)
vectors = mat_lib.get_vector_parameter_names(material)
switches = mat_lib.get_static_switch_parameter_names(material)
textures = mat_lib.get_texture_parameter_names(material)

# NOTE 获取材质的母材质
parent_material = material.get_base_material()

# NOTE 获取 expression (包含所有材质节点)
expressions = py_lib.get_material_expressions(parent_material)
# NOTE 获取所有的参数节点按照 grp : [parameter_name] 排列
paramters = _get_material_paramters(expressions)

collections = defaultdict(dict)
# NOTE 对上面获取的数据重新排序 | 并获取对应的 value 值
for grp, params in sorted(paramters.items()):
for p in sorted(params):
value = None
if p in textures:
func = mat_lib.get_material_instance_texture_parameter_value
value = func(material, p)
value = value.get_path_name()
elif p in switches:
value = mat_lib.get_material_instance_static_switch_parameter_value(
material, p
)
elif p in vectors:
func = mat_lib.get_material_instance_vector_parameter_value
value = func(material, p)
# NOTE 构建 RGBA 字符串
value = "|".join(
[
"%s : %-10s" % (c.upper(), round(getattr(value, c), 2))
for c in "rgba"
]
)
elif p in scalars:
func = mat_lib.get_material_instance_scalar_parameter_value
value = func(material, p)

collections[grp][p] = value

return collections
material = unreal.load_asset('/Game/ParagonShinbi/Characters/Heroes/Shinbi/Materials/M_Shinbi_Legs_Inst_2.M_Shinbi_Legs_Inst_2')
params = get_material_paramters(material)
print(json.dumps(params))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
{
"None": {
"DisplaceValue": "R : 0.0 |G : 0.0 |B : 326.06 |A : 0.0 ",
"SilkROUGHNESS": 4.0,
"HitFlashBrightness": 0.20000000298023224,
"Input Pattern Light": 1.0,
"asdcasdfasdfasdf": 60.0,
"Damage": 0.0,
"Input Pattern Dark": 1.0,
"IriExponent": 0.20000000298023224,
"RGB": "/Game/ParagonShinbi/Characters/Heroes/Shinbi/Textures/T_Shinbi_Lower_RGB.T_Shinbi_Lower_RGB",
"Fabric GrungeTint": "R : 0.73 |G : 0.73 |B : 0.73 |A : 1.0 ",
"BrightnessAdjust": 1.25,
"VertexDisplacePower": 1.5,
"Fabric Trans": 1.0,
"BaseColortint": "R : 0.99 |G : 1.0 |B : 1.1 |A : 1.0 ",
"GoldScratchColor1": "R : 1.0 |G : 1.0 |B : 1.0 |A : 1.0 ",
"IriMask": 0.029999999329447746,
"goldMetGrimeColor": "R : 0.0 |G : 0.0 |B : 0.0 |A : 1.0 ",
"Normal": "/Game/ParagonShinbi/Characters/Heroes/Shinbi/Textures/T_Shinbi_Lower_N.T_Shinbi_Lower_N",
"Color1": "R : 0.1 |G : 0.1 |B : 0.1 |A : 0.0 ",
"Color0": "R : 0.0 |G : 0.0 |B : 0.0 |A : 0.0 ",
"MaskHardness": 2.0,
"fgjgjghg": 4.03448486328125,
"ScaleNormal": "R : 1.0 |G : 1.0 |B : 0.4 |A : 0.0 ",
"RMax": 2.112884998321533,
"Mask": "/Game/ParagonShinbi/Characters/Heroes/Shinbi/Textures/T_Shinbi_Lower_Mask.T_Shinbi_Lower_Mask",
"RMin": 0.20569999516010284,
"Alum Roughness": 0.46039101481437683,
"GoldScratchColor": "R : 0.07 |G : 0.07 |B : 0.07 |A : 1.0 ",
"Mask_1": "/Game/ParagonShinbi/Characters/Heroes/Shinbi/Textures/T_Shinbi_Lower_Mask_1.T_Shinbi_Lower_Mask_1",
"NoiseTextureSize": "R : 100.0 |G : 100.0 |B : 100.0 |A : 0.0 ",
"Fabric Scratch Tint": "R : 1.5 |G : 1.5 |B : 1.5 |A : 1.0 ",
"Color": "/Game/ParagonShinbi/Characters/Heroes/Shinbi/Textures/T_Shinbi_Legs_Colour.T_Shinbi_Legs_Colour",
"BaseColorDiv": "R : 0.27 |G : 0.37 |B : 0.56 |A : 1.0 ",
"adsfsadf": 0.25,
"ScalpColour": "R : 0.2 |G : 0.06 |B : 0.06 |A : 1.0 ",
"asdfasdf": 0.30000001192092896,
"ScratchRopughness": 0.8521010279655457,
"BackMetalColorB": "R : 0.68 |G : 0.64 |B : 0.71 |A : 1.0 "
},
"Variant": {
"Base Silk Color": "R : 0.41 |G : 0.09 |B : 0.31 |A : 1.0 ",
"Scatter Silk Color": "R : 0.22 |G : 0.11 |B : 0.22 |A : 1.0 ",
"Base Fabric Color": "R : 1.0 |G : 1.0 |B : 1.0 |A : 1.0 ",
"AlumMetColor": "R : 2.0 |G : 2.0 |B : 2.0 |A : 1.0 ",
"BackMetalColorA": "R : 0.0 |G : 0.0 |B : 0.0 |A : 1.0 "
},
"FadeDeath": {
"FadeOut": 0.0,
"FadeMask": "/Game/ParagonShinbi/Characters/Global/MaterialFunctions/Textures/T_AtmosphericCloudNoise03.T_AtmosphericCloudNoise03"
}
}

  构建了上面的数据就可以通过 QTreeView 将数据显示出来了。

dayu_widgets MTreeView 显示字典数据

  我当时使用 dayu_widgets 的时候还没有 QTreeView 的使用案例。
  后来我联系作者给提供了一个。

  dayu_widgets 对 MVC 做了不少简化,用法其实和我之前 批量改名工具 大差不差。
  只是数据多了 children 用来做 collapse 用法链接

  用法上先定义 header_list 定义 view 的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
self.property_model = MTableModel()
columns = {u"property": "属性名", u"value": "数值"}
self.property_header_list = [
{
"label": label,
"key": key,
"bg_color": lambda x, y: y.get("bg_color", QtGui.QColor("transparent")),
"checkable": i == 0,
"searchable": True,
"width": 300,
"font": lambda x, y: {"bold": True},
}
for i, (key, label) in enumerate(columns.items())
]
self.property_model.set_header_list(self.property_header_list)

  后续添加数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if isinstance(asset, unreal.MaterialInterface):
data = self.get_material_parameters(asset)
data_list = [
{
"property": group,
"value": "",
"children": [
{
"bg_color": QtGui.QColor("transparent"),
"property": prop,
# NOTE 如果想要选项勾选上可以 用 `{key}_checked` 的属性配置勾选
# "property_checked": QtCore.Qt.Checked,
"value": value,
}
for prop, value in props.items()
],
}
for group, props in data.items()
]
self.property_model.set_data_list(data_list)

  上面 header 定义的 key , 会在 data_list 中作为显示。
  加入 children 数据作为 collapse 数据。
  另外直接设置数据的 bg_color 可以设置背景色。

alt

  我加了功能,当我选择右侧的资产列表时候,左侧的的 TreeView 会检测当前资产下的材质树勾选的选项是否可以传递。
  可以传递标注绿色,不可传递标注红色。

属性传递

  大部分参数设置都可以借助 MaterialEditingLibrary 来完成
  唯独缺了 set_static_switch_parameter_names 的设置函数。

  在日本的网站上可以找别人做的 C++ 函数 https://qiita.com/EGJ-Kaz_Okada/items/4fd6db895b398893cbbb
  直接抄就可以使用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void UPyToolkitBPLibrary::SetMaterialInstanceStaticSwitchParameterValue(UMaterialInstance *Instance, FName ParameterName, bool Value)
{
FStaticParameterSet StaticParameters = Instance->GetStaticParameters();
for (auto &SwitchParameter : StaticParameters.StaticSwitchParameters)
{
if (SwitchParameter.ParameterInfo.Name == ParameterName)
{
SwitchParameter.Value = Value;
break;
;
}
}
Instance->UpdateStaticPermutation(StaticParameters);
}

注: 2021-5-22 更新 上面的方案如果参数没有 override 是不起作用的,新方案如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void URedArtToolkitBPLibrary::SetMaterialInstanceStaticSwitchParameterValue(UMaterialInstance *Instance, FName ParameterName, bool SwitchValue, bool bOverride)
{
TArray<FGuid> Guids;
TArray<FMaterialParameterInfo> OutParameterInfo;
Instance->GetAllStaticSwitchParameterInfo(OutParameterInfo, Guids);
FStaticParameterSet StaticParameters = Instance->GetStaticParameters();

for (int32 ParameterIdx = 0; ParameterIdx < OutParameterInfo.Num(); ParameterIdx++)
{
const FMaterialParameterInfo &ParameterInfo = OutParameterInfo[ParameterIdx];
const FGuid ExpressionId = Guids[ParameterIdx];
if (ParameterInfo.Name == ParameterName)
{
FStaticSwitchParameter *NewParameter =
new (StaticParameters.StaticSwitchParameters) FStaticSwitchParameter(ParameterInfo, SwitchValue, bOverride, ExpressionId);
break;
}
}
Instance->UpdateStaticPermutation(StaticParameters);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
py_lib = unreal.PyToolkitBPLibrary
mat_lib = unreal.MaterialEditingLibrary
def transfer_material_property(self,material, property_list):
scalars = mat_lib.get_scalar_parameter_names(material)
vectors = mat_lib.get_vector_parameter_names(material)
switches = mat_lib.get_static_switch_parameter_names(material)
textures = mat_lib.get_texture_parameter_names(material)

for p in property_list:
if p in textures:
getter = mat_lib.get_material_instance_texture_parameter_value
setter = mat_lib.set_material_instance_texture_parameter_value
elif p in switches:
getter = mat_lib.get_material_instance_static_switch_parameter_value
setter = py_lib.set_material_instance_static_switch_parameter_value
elif p in vectors:
getter = mat_lib.get_material_instance_vector_parameter_value
setter = mat_lib.set_material_instance_vector_parameter_value
elif p in scalars:
getter = mat_lib.get_material_instance_scalar_parameter_value
setter = mat_lib.set_material_instance_scalar_parameter_value
else:
continue
setter(material, p, getter(self.source, p))

  通过上面的方式就可以将不同材质类型对应的数据进行传递了。

总结

  以上就是材质属性传递功能的总结。
  开发的过程还是留下一些遗憾,比如目前还没在面板上制作直接调整属性数据的功能。
  要接入这个功能需要 MVC 的 delegate 来实现。
  dayu_widgets 在这方面还没有做支持,而且还得用 Qt 做一套色板,有点麻烦
  现实需求其实目前这个程度就已经够用了,要修改的话还是用 Unreal 原生编辑器进行修改。