ModelTranslator: support pure-mesh FBX via naming-convention texture discovery and auto material
This commit is contained in:
@@ -38,6 +38,17 @@ def decode_sphere_map(enc01):
|
||||
|
||||
IMG_EXTS = ('.png', '.jpg', '.jpeg', '.tga', '.exr', '.bmp', '.tif', '.tiff')
|
||||
|
||||
# 磁盘贴图命名约定:文件名末段 token -> 贴图槽位
|
||||
DISK_MAP_TOKENS = {
|
||||
"color": "basecolor", "basecolor": "basecolor", "albedo": "basecolor",
|
||||
"diffuse": "basecolor",
|
||||
"normal": "normal", "nrm": "normal",
|
||||
"metallic": "metallic", "metalness": "metallic", "metal": "metallic",
|
||||
"roughness": "roughness", "rough": "roughness",
|
||||
"ao": "ao", "occlusion": "ao", "ambient": "ao",
|
||||
"alpha": "alpha", "opacity": "alpha",
|
||||
}
|
||||
|
||||
|
||||
def _looks_like_ao(name):
|
||||
return bool(AO_NAME_RE.search(name))
|
||||
@@ -54,6 +65,54 @@ def _image_stem(img):
|
||||
return os.path.splitext(os.path.basename(path))[0]
|
||||
|
||||
|
||||
def _load_disk_image(src_dir, fname, warnings):
|
||||
"""加载 FBX 同目录的贴图文件,失败返回 None。"""
|
||||
import bpy
|
||||
try:
|
||||
img = bpy.data.images.load(os.path.join(src_dir, fname), check_existing=True)
|
||||
except RuntimeError as e:
|
||||
warnings.append("贴图 %s 加载失败:%s" % (fname, e))
|
||||
return None
|
||||
if _image_broken(img):
|
||||
warnings.append("贴图 %s 加载失败(0 尺寸)" % fname)
|
||||
return None
|
||||
return img
|
||||
|
||||
|
||||
def _find_maps_on_disk(src_dir, model_name, maps, warnings):
|
||||
"""材质无任何贴图(如纯网格 FBX)时,按命名约定从 FBX 同目录识别整套贴图。
|
||||
文件按 <前缀>_<token>.<ext> 归组;多套前缀时取与模型同名的一组。"""
|
||||
try:
|
||||
files = sorted(os.listdir(src_dir))
|
||||
except OSError:
|
||||
return
|
||||
groups = {} # 前缀 -> {槽位: 文件名}
|
||||
for f in files:
|
||||
stem, ext = os.path.splitext(f)
|
||||
if ext.lower() not in IMG_EXTS or "_" not in stem:
|
||||
continue
|
||||
prefix, token = stem.rsplit("_", 1)
|
||||
key = DISK_MAP_TOKENS.get(token.lower())
|
||||
if key:
|
||||
groups.setdefault(prefix, {}).setdefault(key, f)
|
||||
if not groups:
|
||||
return
|
||||
if model_name in groups:
|
||||
chosen = groups[model_name]
|
||||
elif len(groups) == 1:
|
||||
chosen = next(iter(groups.values()))
|
||||
else:
|
||||
warnings.append("同目录有多套命名约定贴图(前缀:%s),无法确定归属,忽略"
|
||||
% ", ".join(sorted(groups)))
|
||||
return
|
||||
for key, fname in chosen.items():
|
||||
if maps.get(key):
|
||||
continue
|
||||
img = _load_disk_image(src_dir, fname, warnings)
|
||||
if img is not None:
|
||||
maps[key] = (img, None)
|
||||
|
||||
|
||||
def _find_ao_on_disk(src_dir, maps, warnings):
|
||||
"""材质未引用 AO 时,从 FBX 同目录按文件名兜底搜索(烘焙工具常只把 _ao.png 放旁边)。
|
||||
多候选时优先与 basecolor 贴图同前缀(xxx_color.png -> xxx_ao.png)。"""
|
||||
@@ -78,15 +137,7 @@ def _find_ao_on_disk(src_dir, maps, warnings):
|
||||
else:
|
||||
warnings.append("同目录有多张疑似 AO 贴图无法确定归属,忽略:%s" % ", ".join(cands))
|
||||
return None
|
||||
try:
|
||||
img = bpy.data.images.load(os.path.join(src_dir, pick), check_existing=True)
|
||||
except RuntimeError as e:
|
||||
warnings.append("AO 贴图 %s 加载失败:%s" % (pick, e))
|
||||
return None
|
||||
if _image_broken(img):
|
||||
warnings.append("AO 贴图 %s 加载失败(0 尺寸)" % pick)
|
||||
return None
|
||||
return img
|
||||
return _load_disk_image(src_dir, pick, warnings)
|
||||
|
||||
|
||||
def _image_pixels(img):
|
||||
@@ -129,7 +180,7 @@ def _trace_image(socket, warnings, what):
|
||||
return None, None
|
||||
|
||||
|
||||
def _material_maps(mat, warnings, src_dir):
|
||||
def _material_maps(mat, warnings, src_dir, model_name):
|
||||
"""从材质 Principled BSDF 提取贴图与常量。"""
|
||||
maps = {"basecolor": None, "alpha": None, "normal": None,
|
||||
"metallic": None, "roughness": None, "ao": None}
|
||||
@@ -170,6 +221,9 @@ def _material_maps(mat, warnings, src_dir):
|
||||
and not _image_broken(n.image) and _looks_like_ao(n.image.name):
|
||||
maps["ao"] = (n.image, None)
|
||||
break
|
||||
# 纯网格 FBX:材质一张贴图都没有时,按命名约定从同目录识别整套
|
||||
if not any(maps.values()):
|
||||
_find_maps_on_disk(src_dir, model_name, maps, warnings)
|
||||
if maps["ao"] is None:
|
||||
img = _find_ao_on_disk(src_dir, maps, warnings)
|
||||
if img is not None:
|
||||
@@ -205,7 +259,7 @@ def _save_png(path, rgba):
|
||||
|
||||
def _convert_material(mat, model_name, outdir, max_size, used_snames, src_dir):
|
||||
warnings = []
|
||||
maps, consts = _material_maps(mat, warnings, src_dir)
|
||||
maps, consts = _material_maps(mat, warnings, src_dir, model_name)
|
||||
tw, th = _target_size(maps, max_size)
|
||||
|
||||
# --- 基础贴图 ---
|
||||
@@ -258,8 +312,10 @@ def _convert_material(mat, model_name, outdir, max_size, used_snames, src_dir):
|
||||
used_snames.add(sname)
|
||||
if sname != base_sname:
|
||||
warnings.append("材质名 %s 清洗后与其它材质重名,输出改用 %s" % (mat.name, sname))
|
||||
base_png = os.path.abspath(os.path.join(outdir, "%s_%s_base.png" % (model_name, sname)))
|
||||
mix_png = os.path.abspath(os.path.join(outdir, "%s_%s_mix.png" % (model_name, sname)))
|
||||
# 材质名与模型名相同(如纯网格 FBX 的兜底材质)时不重复拼接
|
||||
stem = model_name if sname == model_name else "%s_%s" % (model_name, sname)
|
||||
base_png = os.path.abspath(os.path.join(outdir, "%s_base.png" % stem))
|
||||
mix_png = os.path.abspath(os.path.join(outdir, "%s_mix.png" % stem))
|
||||
_save_png(base_png, base)
|
||||
_save_png(mix_png, mix)
|
||||
|
||||
@@ -299,6 +355,15 @@ def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=src)
|
||||
|
||||
# 纯网格 FBX(无材质槽):以模型名新建材质挂上,否则 Unity 侧没有重映射挂点
|
||||
fallback_mat = None
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH' and not any(s.material for s in obj.material_slots):
|
||||
if fallback_mat is None:
|
||||
fallback_mat = bpy.data.materials.new(model_name)
|
||||
fallback_mat.use_nodes = True
|
||||
obj.data.materials.append(fallback_mat)
|
||||
|
||||
used_mats = []
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != 'MESH':
|
||||
|
||||
Reference in New Issue
Block a user