-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmaterial_optimizer.py
More file actions
2379 lines (1966 loc) · 103 KB
/
material_optimizer.py
File metadata and controls
2379 lines (1966 loc) · 103 KB
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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2026-Present Nehon (github.com/Nehon)
"""
Material Optimizer - Blueprint Function Library
A Python-based Unreal Engine tool that creates optimized Material Instances from
static mesh materials. Exposed to Blueprints via @unreal.uclass() and @unreal.ufunction()
decorators, providing native Blueprint nodes under the "Material Optimizer" category.
Key Features:
- Analyzes static mesh material slots and their texture dependencies
- Creates Material Instances parented to a master material
- Automatically assigns textures to the correct material parameters
- Supports ORM/ARM packed texture workflows
- Confidence-based texture matching with mesh/material/slot name heuristics
- Two-phase workflow: analyze first, then process with user validation
Texture Packing Support:
- ORM/ARM textures: Occlusion(R), Roughness(G), Metallic(B) - NATIVE SUPPORT
- RMA textures: Roughness(R), Metallic(G), AO(B) - AUTO-REPACK (if swizzle material provided)
- RAM textures: Roughness(R), AO(G), Metallic(B) - AUTO-REPACK (if swizzle material provided)
- MRA textures: Metallic(R), Roughness(G), AO(B) - AUTO-REPACK (if swizzle material provided)
Texture Repacking:
When a swizzle_material_path is provided, textures with incompatible channel orders
(RMA, RAM, MRA) are automatically repacked to ORM format using GPU rendering.
This is a format-agnostic system - the swizzle material defines the channel remapping,
making it extensible to any custom packing format.
Blueprint Usage:
The BPMaterialOptimizer class exposes two Blueprint-callable functions:
1. "Analyze Selected Meshes" node:
- Analyzes Content Browser selected meshes
- Returns JSON with texture candidates, conflicts, and confidence scores
- No changes made to assets (read-only analysis)
2. "Optimize Materials From Analysis" node:
- Takes analysis JSON (potentially modified by user to resolve conflicts)
- Creates Material Instances and assigns textures
- Assigns new MIs to mesh material slots
Two-Phase Workflow:
Phase 1 - Analysis:
Call "Analyze Selected Meshes" to get a JSON report of all meshes,
their material slots, detected textures, and any conflicts.
Phase 2 - Processing:
Pass the analysis JSON to "Optimize Materials From Analysis".
The JSON can be modified between phases to:
- Set 'selected': true on preferred texture candidates
- Set 'skip': true on meshes to exclude
- Set channel values to true in 'skipped_channels' to ignore specific texture types
Output:
Results are logged to the Output Log and returned as JSON strings.
"""
import unreal
import json
from typing import Optional, List, Dict, Any
# =============================================================================
# Default texture patterns
# =============================================================================
DEFAULT_PATTERNS = {
"BaseColor": ["_BaseColor", "_Diffuse", "_Albedo", "_Color", "_Base_Color","_BC", "_d", "_diff", "_B"],
"Normal": ["_Normal", "_Nrm", "_N", "_NormalMap", "_norm", "_NM", "_nor"],
"Roughness": ["_Roughness", "_Rough", "_R", "_roughness"],
"Metallic": ["_Metallic", "_Metal", "_M", "_metallic", "_Metalness"],
"AO": ["_AO", "_Occlusion", "_AmbientOcclusion", "_O", "_ao"],
"Emissive": ["_Emissive", "_Emission", "_E", "_Glow", "_emissive"]
}
# Patterns for ORM/ARM packed textures (COMPATIBLE - same channel order: Occlusion/AO(R), Roughness(G), Metallic(B))
ORM_PATTERNS = ["_ORM", "_ARM", "_OcclusionRoughnessMetallic", "_AORoughnessMetallic","_MetalRough", "_HRM"]
# Patterns for RMA packed textures (INCOMPATIBLE - different channel order: Roughness(R), Metallic(G), AO(B))
RMA_PATTERNS = ["_RMA", "_RoughnessMetallicAO", "_RoughnessMetalAO"]
# Patterns for RAM packed textures (INCOMPATIBLE - different channel order: Roughness(R), AO(G), Metallic(B))
RAM_PATTERNS = ["_RAM", "_RoughnessAOMetallic", "_RoughnessAOMetal", "_RoughAOMetal", "-RAM", "RAM_"]
# Patterns for MRA packed textures (INCOMPATIBLE - different channel order: Metallic(R), Roughness(G), AO(B))
MRA_PATTERNS = ["_MRA", "_MetallicRoughnessAO", "_MetalRoughAO", "_MT_R_AO"]
def pattern_matches_full_word(texture_name_lower: str, pattern_lower: str) -> bool:
"""Check if pattern matches as a full word/suffix in texture name.
Prevents false positives like "_d" matching "_dx" or "_r" matching "_rock".
Pattern matches if it's found in the texture name and is NOT immediately
followed by a letter (a-z).
Args:
texture_name_lower: Lowercase texture name to search in
pattern_lower: Lowercase pattern to search for
Returns:
True if pattern matches as a complete suffix/word
"""
idx = texture_name_lower.find(pattern_lower)
while idx != -1:
end_idx = idx + len(pattern_lower)
# Check if pattern is at end of string or followed by non-letter
if end_idx >= len(texture_name_lower):
return True # Pattern is at the end
next_char = texture_name_lower[end_idx]
if not next_char.isalpha():
return True # Pattern is followed by non-letter (digit, underscore, etc.)
# Look for next occurrence
idx = texture_name_lower.find(pattern_lower, end_idx)
return False
MATERIAL_PARAMETER_NAMES = {
"BaseColor": ["BaseColor", "BaseColorMap", "Base Color", "Diffuse", "Albedo", "Color"],
"Normal": ["Normal", "NormalMap", "Normal Map"],
"Roughness": ["Roughness","RoughnessMap", "Rough"],
"Metallic": ["Metallic", "Matalness", "MetallicMap", "MetalnessMap", "Metal"],
"AO": ["AO", "AmbientOcclusion", "OcclusionMap", "Ambient Occlusion", "Occlusion"],
"Emissive": ["Emissive", "Emission", "EmissiveMap", "EmissionMap", "EmissiveColor"]
}
# Parameter names for ORM master material
MATERIAL_PARAMETER_NAMES_ORM = {
"BaseColor": ["BaseColor", "BaseColorMap", "Base Color", "Base Color Map", "Diffuse", "DiffuseMap", "Diffuse Map", "Albedo", "AlbedoMap", "Albedo Map", "Color", "ColorMap", "Color Map"],
"Normal": ["Normal", "NormalMap", "Normal Map"],
"ORM": ["ORM Map", "ORM", "ARM", "OcclusionRoughnessMetallic", "PackedTexture"],
"Emissive": ["Emissive", "Emission", "EmissiveMap", "EmissionMap", "EmissiveColor"]
}
# =============================================================================
# Material Compatibility Checking
# =============================================================================
@unreal.uclass()
class BPMaterialOptimizer(unreal.BlueprintFunctionLibrary):
@staticmethod
def get_base_material(material) -> Optional[unreal.Material]:
"""Recursively get the base Material from a Material or MaterialInstance.
Args:
material: A Material or MaterialInstance asset
Returns:
The base Material, or None if not found
"""
if material is None:
return None
# If it's already a Material (not an instance), return it
if isinstance(material, unreal.Material):
return material
# If it's a MaterialInstance, get parent and recurse
if isinstance(material, unreal.MaterialInstance):
parent = material.get_editor_property("parent")
return BPMaterialOptimizer.get_base_material(parent)
return None
@staticmethod
def get_material_shading_info(material: unreal.Material) -> dict:
"""Get shading-related properties from a Material.
Args:
material: A Material asset
Returns:
Dict with material_domain, blend_mode, shading_model
"""
if not material or not isinstance(material, unreal.Material):
return {}
return {
"material_domain": material.get_editor_property("material_domain"),
"blend_mode": material.get_editor_property("blend_mode"),
"shading_model": material.get_editor_property("shading_model"),
}
@staticmethod
def check_material_compatibility(mesh_material, master_material: unreal.Material) -> tuple:
"""Check if a mesh's material is compatible with the master material.
Compares Material Domain, Blend Mode, and Shading Model.
Args:
mesh_material: The material currently on the mesh (can be Material or MaterialInstance)
master_material: The master material to compare against
Returns:
Tuple of (is_compatible: bool, reason: str)
"""
if mesh_material is None:
return False, "No material assigned"
# Get base material from mesh's material (in case it's an instance)
mesh_base = BPMaterialOptimizer.get_base_material(mesh_material)
if mesh_base is None:
return False, "Could not find base material"
# Get master's base material (in case user provided an instance)
master_base = BPMaterialOptimizer.get_base_material(master_material)
if master_base is None:
return False, "Could not find master base material"
# Get shading info
mesh_info = BPMaterialOptimizer.get_material_shading_info(mesh_base)
master_info = BPMaterialOptimizer.get_material_shading_info(master_base)
if not mesh_info or not master_info:
return False, "Could not read material properties"
# Compare properties
mismatches = []
if mesh_info["material_domain"] != master_info["material_domain"]:
mismatches.append(f"Domain: {mesh_info['material_domain']} vs {master_info['material_domain']}")
if mesh_info["blend_mode"] != master_info["blend_mode"]:
mismatches.append(f"BlendMode: {mesh_info['blend_mode']} vs {master_info['blend_mode']}")
if mesh_info["shading_model"] != master_info["shading_model"]:
mismatches.append(f"ShadingModel: {mesh_info['shading_model']} vs {master_info['shading_model']}")
if mismatches:
return False, "; ".join(mismatches)
return True, ""
# =============================================================================
# Texture Discovery from Material Dependencies
# =============================================================================
@staticmethod
def get_mesh_material_slots(mesh: unreal.StaticMesh) -> list:
"""Get all material slots from a static mesh with detailed info.
Args:
mesh: The static mesh to examine
Returns:
List of dicts with keys: 'index', 'slot_name', 'material'
"""
slots = []
static_materials = mesh.get_editor_property("static_materials")
for i, static_mat in enumerate(static_materials):
mat_interface = static_mat.get_editor_property("material_interface")
slot_name = str(static_mat.get_editor_property("material_slot_name"))
# Use index-based name if slot name is empty or "None"
if not slot_name or slot_name == "None":
slot_name = f"Slot{i}"
slots.append({
'index': i,
'slot_name': slot_name,
'material': mat_interface
})
return slots
@staticmethod
def get_mesh_materials(mesh: unreal.StaticMesh) -> list:
"""Get all materials assigned to a static mesh."""
slots = BPMaterialOptimizer.get_mesh_material_slots(mesh)
return [slot['material'] for slot in slots if slot['material']]
@staticmethod
def get_material_texture_dependencies(material) -> list:
"""Get all texture assets that a material depends on.
Args:
material: A Material or MaterialInstance asset
Returns:
List of texture assets used by the material
"""
if not material:
return []
material_path = material.get_path_name()
# Remove the asset name suffix (e.g., "/Game/Mat/M_Test.M_Test" -> "/Game/Mat/M_Test")
if '.' in material_path:
material_path = material_path.split('.')[0]
asset_registry = unreal.AssetRegistryHelpers.get_asset_registry()
# Get dependencies of the material
dependencies = asset_registry.get_dependencies(
material_path,
unreal.AssetRegistryDependencyOptions()
)
textures = []
for dep_path in dependencies:
dep_path_str = str(dep_path)
# Load the asset to check if it's a texture
asset = unreal.EditorAssetLibrary.load_asset(dep_path_str)
if asset and isinstance(asset, unreal.Texture):
textures.append(asset)
return textures
@staticmethod
def get_textures_from_mesh_materials(mesh: unreal.StaticMesh) -> list:
"""Get all textures used by a mesh's materials.
This finds textures by examining the mesh's material dependencies,
which is more reliable than name-based matching.
Args:
mesh: The static mesh to examine
Returns:
List of unique texture assets
"""
all_textures = []
seen_paths = set()
materials = BPMaterialOptimizer.get_mesh_materials(mesh)
for material in materials:
textures = BPMaterialOptimizer.get_material_texture_dependencies(material)
for tex in textures:
tex_path = tex.get_path_name()
if tex_path not in seen_paths:
seen_paths.add(tex_path)
all_textures.append(tex)
return all_textures
@staticmethod
def find_textures_from_mesh(mesh: unreal.StaticMesh, patterns: dict = None, packing_mode: str = "STANDARD") -> dict:
"""Find and categorize textures from a mesh's existing materials.
This is the preferred method - it finds textures by examining what
the mesh's current materials actually use, then categorizes them
by channel type based on naming patterns.
Args:
mesh: The static mesh to examine
patterns: Texture type patterns dict
packing_mode: "STANDARD" for separate textures, "ORM" for packed ORM textures
Returns:
Dict mapping texture types to texture assets
"""
if patterns is None:
patterns = DEFAULT_PATTERNS
textures = BPMaterialOptimizer.get_textures_from_mesh_materials(mesh)
if not textures:
return {}
unreal.log(f" Found {len(textures)} textures from mesh materials:")
matched_textures = {}
include_orm = (packing_mode == "ORM")
for texture in textures:
tex_name = texture.get_name()
tex_type = BPMaterialOptimizer.match_texture_to_type(tex_name, patterns, include_orm=include_orm)
if tex_type:
if tex_type not in matched_textures:
matched_textures[tex_type] = texture
unreal.log(f" [{tex_type}] {tex_name}")
else:
unreal.log(f" [{tex_type}] {tex_name} (duplicate, skipped)")
else:
unreal.log(f" [Unknown] {tex_name}")
return matched_textures
# =============================================================================
# Virtual Texture Detection
# =============================================================================
@staticmethod
def detect_texture_vt_status(textures: list) -> Dict[str, Any]:
"""Check Virtual Texture (VT) status of a list of textures.
Examines each texture to determine if it uses Virtual Texture streaming.
This information is used for compatibility checking with master materials
that may or may not support VT samplers.
Args:
textures: List of texture assets to examine
Returns:
Dict with VT status information:
{
'has_vt': bool, # At least one texture is VT
'has_non_vt': bool, # At least one texture is non-VT
'all_vt': bool, # All textures are VT
'all_non_vt': bool, # All textures are non-VT
'vt_textures': list, # List of VT texture paths
'non_vt_textures': list, # List of non-VT texture paths
}
"""
vt_textures = []
non_vt_textures = []
for texture in textures:
if not texture:
continue
# Get texture path for reporting
tex_path = texture.get_path_name()
if '.' in tex_path:
tex_path = tex_path.split('.')[0]
# Check VT status
is_vt = False
try:
is_vt = texture.get_editor_property('VirtualTextureStreaming')
except:
pass # Property might not exist on some texture types
if is_vt:
vt_textures.append(tex_path)
else:
non_vt_textures.append(tex_path)
has_vt = len(vt_textures) > 0
has_non_vt = len(non_vt_textures) > 0
total = len(vt_textures) + len(non_vt_textures)
return {
'has_vt': has_vt,
'has_non_vt': has_non_vt,
'all_vt': has_vt and not has_non_vt and total > 0,
'all_non_vt': has_non_vt and not has_vt and total > 0,
'vt_textures': vt_textures,
'non_vt_textures': non_vt_textures,
}
# =============================================================================
# ORM/ARM Packing Detection
# =============================================================================
@staticmethod
def detect_texture_packing_mode(textures: list, custom_packed_prefix: str = None) -> str:
"""Detect if textures use ORM/ARM packing or separate channels.
Examines texture names to determine which packing mode is used.
Args:
textures: List of texture assets to examine
custom_packed_prefix: Optional custom suffix pattern for packed textures
(e.g., "_MADS"). If a texture matches this suffix, returns "CUSTOM".
Returns:
"ORM" if ORM/ARM packed textures detected (compatible)
"RMA" if RMA packed textures detected (requires repacking)
"RAM" if RAM packed textures detected (requires repacking)
"MRA" if MRA packed textures detected (requires repacking)
"CUSTOM" if custom packed textures detected (requires repacking)
"STANDARD" if separate channel textures (no packed texture found)
"""
for texture in textures:
tex_name = texture.get_name().lower()
# Check for ORM/ARM patterns first (compatible)
for pattern in ORM_PATTERNS:
if pattern_matches_full_word(tex_name, pattern.lower()):
return "ORM"
# Check for RMA patterns (requires repacking)
for pattern in RMA_PATTERNS:
if pattern_matches_full_word(tex_name, pattern.lower()):
return "RMA"
# Check for RAM patterns (requires repacking)
for pattern in RAM_PATTERNS:
if pattern_matches_full_word(tex_name, pattern.lower()):
return "RAM"
# Check for MRA patterns (requires repacking)
for pattern in MRA_PATTERNS:
if pattern_matches_full_word(tex_name, pattern.lower()):
return "MRA"
# Check for custom packed prefix
if custom_packed_prefix:
if pattern_matches_full_word(tex_name, custom_packed_prefix.lower()):
return "CUSTOM"
return "STANDARD"
# =============================================================================
# Texture Conflict Analysis (Two-Phase Workflow)
# =============================================================================
@staticmethod
def get_all_texture_candidates(
textures: list,
patterns: dict = None,
include_orm: bool = False,
context_names: List[str] = None,
packing_mode: str = None,
custom_packed_prefix: str = None
) -> Dict[str, List[Dict[str, Any]]]:
"""Categorize all textures, keeping ALL candidates per type.
Unlike match_texture_to_type which returns only the first match,
this function collects ALL textures that match each type.
Confidence scoring:
- Base confidence: 1.0 if only one texture matches the channel, else 1.0 / num_matches
- Name bonus: +0.3 if texture name contains any of the context names (mesh/material/slot)
- Results sorted by confidence descending
- Auto-select the highest confidence texture if >= 1.0
Args:
textures: List of texture assets to categorize
patterns: Texture type patterns dict (default: DEFAULT_PATTERNS)
include_orm: If True, also check for ORM packed texture patterns (legacy)
context_names: Optional list of context names (mesh name, material name, slot name)
used for confidence bonus when texture name contains them
packing_mode: If provided, check for packed texture patterns matching this mode.
Values: 'ORM', 'RMA', 'RAM', 'MRA', 'CUSTOM'. Overrides include_orm if set.
custom_packed_prefix: Custom suffix pattern for packed textures (e.g., "_MADS").
Used when packing_mode is 'CUSTOM'.
Returns:
Dict mapping texture types to lists of candidate dicts:
{
'BaseColor': [
{'name': 'T_Wood_BaseColor', 'path': '/Game/...', 'confidence': 1.0, 'selected': True},
{'name': 'T_Moss_BaseColor', 'path': '/Game/...', 'confidence': 0.8, 'selected': False}
],
...
}
"""
if patterns is None:
patterns = DEFAULT_PATTERNS
# Normalize context names for case-insensitive matching
normalized_context = []
if context_names:
for name in context_names:
if name:
# Also try without common prefixes
name_lower = name.lower()
normalized_context.append(name_lower)
# Remove common prefixes for better matching
for prefix in ["sm_", "s_", "m_", "mi_", "mat_", "t_", "tex_"]:
if name_lower.startswith(prefix):
normalized_context.append(name_lower[len(prefix):])
break
candidates: Dict[str, List[Dict[str, Any]]] = {}
# First pass: collect all matching textures per type
# A texture can match multiple types - we check ALL patterns for ALL types
for texture in textures:
tex_name = texture.get_name()
tex_path = texture.get_path_name()
if '.' in tex_path:
tex_path = tex_path.split('.')[0]
tex_name_lower = tex_name.lower()
matched_types = set() # Track which types this texture matched
# Check for packed texture patterns based on packing_mode
# Map packing mode to pattern list
packed_pattern_map = {
'ORM': ORM_PATTERNS,
'RMA': RMA_PATTERNS,
'RAM': RAM_PATTERNS,
'MRA': MRA_PATTERNS,
}
if custom_packed_prefix:
packed_pattern_map['CUSTOM'] = [custom_packed_prefix]
# Check for patterns matching the detected packing mode
# Always store as 'ORM' since that's the target format (RMA/RAM/MRA will be repacked to ORM)
if packing_mode and packing_mode in packed_pattern_map:
pattern_list = packed_pattern_map[packing_mode]
for pattern in pattern_list:
if pattern_matches_full_word(tex_name_lower, pattern.lower()):
matched_types.add('ORM') # Always use ORM as the channel name
break
# Legacy support: include_orm flag
elif include_orm:
for pattern in ORM_PATTERNS:
if pattern_matches_full_word(tex_name_lower, pattern.lower()):
matched_types.add('ORM')
break
# Check ALL standard patterns (don't skip after first match)
for tex_type, pattern_list in patterns.items():
for pattern in pattern_list:
if pattern_matches_full_word(tex_name_lower, pattern.lower()):
matched_types.add(tex_type)
break # One pattern match per type is enough
# Add texture to all matched types
for tex_type in matched_types:
if tex_type not in candidates:
candidates[tex_type] = []
candidates[tex_type].append({
'name': tex_name,
'path': tex_path,
'confidence': 0.0,
'selected': False
})
# Second pass: calculate confidence scores
for tex_type, tex_list in candidates.items():
num_matches = len(tex_list)
for tex_entry in tex_list:
# Base confidence: 1.0 if single match, else 1/N
base_confidence = 1.0 if num_matches == 1 else 1.0 / num_matches
# Name bonus: +0.3 if texture name contains any context name
name_bonus = 0.0
if normalized_context:
tex_name_lower = tex_entry['name'].lower()
for ctx_name in normalized_context:
if ctx_name in tex_name_lower:
name_bonus = 0.3
break
else:
distance = BPMaterialOptimizer.levenshtein_distance(ctx_name, tex_name_lower)
max_len = max(len(ctx_name), len(tex_name_lower))
if max_len == 0:
continue
similarity = 1.0 - (distance / max_len)
name_bonus += similarity * 0.01
tex_entry['confidence'] = base_confidence + name_bonus
# Sort by confidence descending
tex_list.sort(key=lambda x: x['confidence'], reverse=True)
# Auto-select the first (highest confidence) if >= 1.0
if tex_list and tex_list[0]['confidence'] >= 1.0:
tex_list[0]['selected'] = True
# If a packed texture (ORM) is detected, always remove Roughness, Metallic, and AO
# since packed textures contain all three channels.
# Note: RMA/RAM/MRA textures are also stored under 'ORM' key (target format)
if 'ORM' in candidates:
orm_list = candidates['ORM']
if orm_list:
# Always remove individual channels when a packed texture exists
for channel in ['Roughness', 'Metallic', 'AO']:
if channel in candidates:
del candidates[channel]
# Third pass: remove textures from channels where they have low confidence
# if they have >= 1.0 confidence in another channel
# Build a map of textures that have high confidence in at least one channel
high_confidence_assignments: Dict[str, str] = {} # tex_path -> tex_type with high confidence
for tex_type, tex_list in candidates.items():
for tex_entry in tex_list:
if tex_entry['confidence'] >= 1.0:
tex_path = tex_entry['path']
# Only record if not already recorded (first high-confidence wins)
if tex_path not in high_confidence_assignments:
high_confidence_assignments[tex_path] = tex_type
# Remove textures from other channels if they have high confidence elsewhere
for tex_type, tex_list in list(candidates.items()):
candidates[tex_type] = [
tex_entry for tex_entry in tex_list
if tex_entry['path'] not in high_confidence_assignments
or high_confidence_assignments[tex_entry['path']] == tex_type
]
# Remove empty channels
if not candidates[tex_type]:
del candidates[tex_type]
return candidates
@staticmethod
def _analyze_slot(
slot_material,
master_material,
master_material_orm,
slot_index: int,
slot_name: str,
mesh_name: str,
swizzle_material_path: str = None,
master_material_supports_vt: bool = False,
master_material_orm_supports_vt: bool = False,
convert_vt_textures: bool = False,
custom_packed_prefix: str = None
) -> Dict[str, Any]:
"""Analyze a single material slot for texture conflicts.
Args:
slot_material: The material in this slot
master_material: Standard master material
master_material_orm: ORM master material (optional)
slot_index: Slot index
slot_name: Slot name
mesh_name: Name of the mesh (for confidence scoring)
swizzle_material_path: If provided, enables texture repacking for
incompatible formats (RMA, RAM, MRA) instead of skipping them
master_material_supports_vt: Whether the standard master material supports VT
master_material_orm_supports_vt: Whether the ORM master material supports VT
convert_vt_textures: If True, VT mismatches won't be flagged as incompatible
custom_packed_prefix: Optional custom suffix pattern for packed textures
(e.g., "_MADS"). Enables detection of custom packed formats.
Returns:
SlotAnalysis dict
"""
# Get material path (cleaned)
source_material_path = None
if slot_material:
source_material_path = slot_material.get_path_name()
if '.' in source_material_path:
source_material_path = source_material_path.split('.')[0]
slot_analysis = {
'slot_index': slot_index,
'slot_name': slot_name,
'source_material_path': source_material_path,
'packing_mode': 'STANDARD',
'is_compatible': False,
'skip_reason': None,
'texture_matches': {},
'has_conflicts': False,
'conflict_types': [],
'skipped_channels': {},
'total_issues': 0,
'needs_repack': False,
'vt_status': {},
'needs_vt_conversion': False,
'vt_conversion_direction': None
}
if not slot_material:
slot_analysis['skip_reason'] = 'No material assigned'
slot_analysis['total_issues'] = 1 # Incompatibility issue
return slot_analysis
# Get textures from material
raw_textures = BPMaterialOptimizer.get_material_texture_dependencies(slot_material)
if not raw_textures:
slot_analysis['skip_reason'] = 'No textures found in material'
slot_analysis['total_issues'] = 1 # Incompatibility issue
return slot_analysis
# Detect packing mode
packing_mode = BPMaterialOptimizer.detect_texture_packing_mode(raw_textures, custom_packed_prefix)
slot_analysis['packing_mode'] = packing_mode
# Handle incompatible packing formats (RMA, RAM, MRA, CUSTOM)
if packing_mode in ['RMA', 'RAM', 'MRA', 'CUSTOM']:
if swizzle_material_path:
# Repacking enabled - mark for conversion, continue processing
slot_analysis['needs_repack'] = True
# Will use ORM master material for the converted texture
if not master_material_orm:
slot_analysis['skip_reason'] = f'{packing_mode} textures - repacking enabled but no ORM master material provided'
slot_analysis['total_issues'] = 1
return slot_analysis
selected_master = master_material_orm
else:
# No swizzle material - treat as incompatible (original behavior)
slot_analysis['skip_reason'] = f'{packing_mode} packed textures (incompatible channel order)'
slot_analysis['total_issues'] = 1
return slot_analysis
# Select master material based on packing mode
elif packing_mode == 'ORM':
if not master_material_orm:
slot_analysis['skip_reason'] = 'ORM textures detected but no ORM material provided'
slot_analysis['total_issues'] = 1 # Incompatibility issue
return slot_analysis
selected_master = master_material_orm
else:
selected_master = master_material
# Check compatibility
is_compatible, reason = BPMaterialOptimizer.check_material_compatibility(slot_material, selected_master)
slot_analysis['is_compatible'] = is_compatible
if not is_compatible:
slot_analysis['skip_reason'] = f'Incompatible: {reason}'
slot_analysis['total_issues'] = 1 # Incompatibility issue
return slot_analysis
# Detect VT status of textures
vt_status = BPMaterialOptimizer.detect_texture_vt_status(raw_textures)
slot_analysis['vt_status'] = vt_status
# Determine which VT flag to use based on selected master material
# If using ORM master (packed textures), use ORM VT flag; otherwise use standard
if packing_mode in ['ORM', 'RMA', 'RAM', 'MRA', 'CUSTOM']:
material_supports_vt = master_material_orm_supports_vt
else:
material_supports_vt = master_material_supports_vt
# Check VT compatibility
# Case 1: Material supports VT but all textures are non-VT -> need to convert to VT
if material_supports_vt and vt_status['all_non_vt']:
slot_analysis['needs_vt_conversion'] = True
slot_analysis['vt_conversion_direction'] = 'to_vt'
# Case 2: Material doesn't support VT but has VT textures -> need to convert to non-VT
elif not material_supports_vt and vt_status['has_vt']:
slot_analysis['needs_vt_conversion'] = True
slot_analysis['vt_conversion_direction'] = 'to_non_vt'
# If VT conversion is needed but not enabled, mark as incompatible
if slot_analysis['needs_vt_conversion'] and not convert_vt_textures:
direction = slot_analysis['vt_conversion_direction']
if direction == 'to_vt':
slot_analysis['skip_reason'] = 'VT mismatch: master material requires Virtual Textures but source textures are non-VT'
else:
slot_analysis['skip_reason'] = 'VT mismatch: master material does not support Virtual Textures but source textures are VT'
slot_analysis['is_compatible'] = False
slot_analysis['total_issues'] = 1
return slot_analysis
# Get all texture candidates with context for confidence scoring
material_name = slot_material.get_name() if slot_material else None
context_names = [mesh_name, material_name, slot_name]
# Pass packing_mode to capture packed textures (ORM, RMA, RAM, MRA)
candidates = BPMaterialOptimizer.get_all_texture_candidates(
raw_textures,
context_names=context_names,
packing_mode=packing_mode if packing_mode != 'STANDARD' else None,
custom_packed_prefix=custom_packed_prefix
)
slot_analysis['texture_matches'] = candidates
# Build skipped_channels: false for each detected channel (user can set to true to skip)
slot_analysis['skipped_channels'] = {tex_type: False for tex_type in candidates.keys()}
# Check for conflicts (more than one candidate per type) and unselected channels
unselected_channels = []
for tex_type, tex_list in candidates.items():
if len(tex_list) > 1:
slot_analysis['has_conflicts'] = True
slot_analysis['conflict_types'].append(tex_type)
# Check if any texture is selected for this channel
has_selection = any(tex.get('selected', False) for tex in tex_list)
if not has_selection:
unselected_channels.append(tex_type)
# Calculate total_issues: conflicts + unselected channels (no texture with confidence >= 1.0)
# Note: conflicts are a subset of unselected (if multiple candidates, none auto-selected means conflict)
# So we just count unselected_channels which includes both cases
slot_analysis['total_issues'] = len(unselected_channels)
return slot_analysis
@staticmethod
def _analyze_mesh(
mesh: unreal.StaticMesh,
master_material,
master_material_orm,
swizzle_material_path: str = None,
master_material_supports_vt: bool = False,
master_material_orm_supports_vt: bool = False,
convert_vt_textures: bool = False,
custom_packed_prefix: str = None
) -> Dict[str, Any]:
"""Analyze a single mesh for texture conflicts.
Args:
mesh: The static mesh to analyze
master_material: Standard master material
master_material_orm: ORM master material (optional)
swizzle_material_path: If provided, enables texture repacking for
incompatible formats (RMA, RAM, MRA, CUSTOM) instead of skipping them
master_material_supports_vt: Whether the standard master material supports VT
master_material_orm_supports_vt: Whether the ORM master material supports VT
convert_vt_textures: If True, VT mismatches won't be flagged as incompatible
custom_packed_prefix: Optional custom suffix pattern for packed textures
Returns:
MeshAnalysis dict
"""
mesh_path = mesh.get_path_name()
if '.' in mesh_path:
mesh_path = mesh_path.split('.')[0]
mesh_name = mesh.get_name()
mesh_analysis = {
'mesh_name': mesh_name,
'mesh_path': mesh_path,
'slots': [],
'has_any_conflicts': False,
'skip': False,
'has_any_incompatibility': False,
'total_issues': 0
}
material_slots = BPMaterialOptimizer.get_mesh_material_slots(mesh)
if not material_slots:
return mesh_analysis
for slot in material_slots:
slot_analysis = BPMaterialOptimizer._analyze_slot(
slot['material'],
master_material,
master_material_orm,
slot['index'],
slot['slot_name'],
mesh_name,
swizzle_material_path,
master_material_supports_vt,
master_material_orm_supports_vt,
convert_vt_textures,
custom_packed_prefix
)
mesh_analysis['slots'].append(slot_analysis)
if slot_analysis['has_conflicts']:
mesh_analysis['has_any_conflicts'] = True
if not slot_analysis['is_compatible']:
mesh_analysis['has_any_incompatibility'] = True
# don't add the number of issues if the slot is incompatible, it won't be processed
continue
# Sum up slot issues
mesh_analysis['total_issues'] += slot_analysis['total_issues']
return mesh_analysis
# =============================================================================
# Helper Functions
# =============================================================================
@staticmethod
def normalize_content_path(path: str) -> str:
"""Normalize a content path to ensure it starts with /Game."""
if not path:
return path
if not path.startswith("/"):
path = "/" + path
if not path.startswith("/Game"):
path = "/Game" + path
return path
@staticmethod
def ensure_folder_exists(folder_path: str) -> bool:
"""Create a content folder if it doesn't exist.
Args:
folder_path: Content path like "/Game/Materials/Instances"
Returns:
True if folder exists or was successfully created
"""
folder_path = BPMaterialOptimizer.normalize_content_path(folder_path)
if unreal.EditorAssetLibrary.does_directory_exist(folder_path):
return True
# Create the folder
success = unreal.EditorAssetLibrary.make_directory(folder_path)
if success:
unreal.log(f" Created folder: {folder_path}")
else:
unreal.log_error(f" Failed to create folder: {folder_path}")
return success
# Texture properties to preserve when repacking
TEXTURE_SETTINGS_TO_PRESERVE = [
'LODBias',
'MaxTextureSize',
'AdjustBrightness',
'AdjustBrightnessCurve',
'AdjustVibrance',
'AdjustSaturation',
'AdjustRGBCurve',
'AdjustHue',
'AdjustMinAlpha',
'AdjustMaxAlpha',
]
# Default values for texture settings (used when resetting for clean render)
TEXTURE_SETTINGS_DEFAULTS = {
'LODBias': 0,
'MaxTextureSize': 0,
'AdjustBrightness': 1.0,
'AdjustBrightnessCurve': 1.0,
'AdjustVibrance': 0.0,
'AdjustSaturation': 1.0,
'AdjustRGBCurve': 1.0,
'AdjustHue': 0.0,
'AdjustMinAlpha': 0.0,
'AdjustMaxAlpha': 1.0,