From 253f03273d259a2d7c4c23b16e4c51a78d75ac09 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 15 Jul 2026 16:39:00 +0800 Subject: [PATCH] ModelTranslator: support pure-mesh FBX via naming-convention texture discovery and auto material --- Tools/ModelTranslator/README.md | 1 + Tools/ModelTranslator/bl_convert.py | 91 +++++++++++++++++++---- Tools/ModelTranslator/model_translator.py | 4 +- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/Tools/ModelTranslator/README.md b/Tools/ModelTranslator/README.md index 4a01d311..91298b1a 100644 --- a/Tools/ModelTranslator/README.md +++ b/Tools/ModelTranslator/README.md @@ -38,6 +38,7 @@ out/tong/ - 贴图识别:沿材质 Principled BSDF 连线找 Base Color / Alpha / Normal / Metallic / Roughness;内嵌与外置贴图(FBX 旁的松散文件)均支持 - AO 按贴图名匹配(含 `ao`/`occlusion`/`ambient`):先找材质内引用的贴图;FBX 未引用时自动搜索 FBX 同目录的图片文件,多候选优先与颜色贴图同前缀(`xxx_color.png` → `xxx_ao.png`) +- **纯网格 FBX**(无材质或材质无贴图,常见于烘焙工具导出):自动以模型名新建材质,并按命名约定从 FBX 同目录识别整套贴图——`<前缀>_.png`,token 支持 `color/basecolor/albedo/diffuse`、`normal/nrm`、`metallic/metalness/metal`、`roughness/rough`、`ao/occlusion/ambient`、`alpha/opacity`;多套前缀时取与模型同名的一组 - 基础贴图 A 通道自动判定:**透明贴图 > 颜色贴图自带 alpha > AO 贴图 > 填白(255)**;判定为透明时 `.mat` 自动勾选 `Alpha As Transparency`(AlphaTest 裁剪) - 无对应贴图的通道用 Principled 常量值填充(会在日志中标注 `(常量)`) - 目标尺寸 = min(2048, 源贴图最大边),所有贴图缩放到统一尺寸 diff --git a/Tools/ModelTranslator/bl_convert.py b/Tools/ModelTranslator/bl_convert.py index f0488f3f..eeef91ff 100644 --- a/Tools/ModelTranslator/bl_convert.py +++ b/Tools/ModelTranslator/bl_convert.py @@ -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 同目录识别整套贴图。 + 文件按 <前缀>_. 归组;多套前缀时取与模型同名的一组。""" + 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': diff --git a/Tools/ModelTranslator/model_translator.py b/Tools/ModelTranslator/model_translator.py index 168a88ed..9bc79f8b 100644 --- a/Tools/ModelTranslator/model_translator.py +++ b/Tools/ModelTranslator/model_translator.py @@ -51,7 +51,9 @@ def write_unity_assets(outdir, summary, max_size): with open(mix_png + ".meta", "w", encoding="utf-8", newline="\n") as f: f.write(ua.texture_meta(mix_guid, srgb=False, max_size=max_size)) - mat_name = "%s_%s" % (summary["model"], m["safe_name"]) + # 与 bl_convert 的贴图命名一致:材质名等于模型名时不重复拼接 + mat_name = summary["model"] if m["safe_name"] == summary["model"] \ + else "%s_%s" % (summary["model"], m["safe_name"]) mat_path = os.path.join(outdir, mat_name + ".mat") mat_guid = ua.guid_for(mat_path) with open(mat_path, "w", encoding="utf-8", newline="\n") as f: