From 78b250355ba908a09733759ef33e9b9da8aeef43 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Wed, 15 Jul 2026 12:16:24 +0800 Subject: [PATCH] ModelTranslator: support external-texture FBX (disk AO fallback search, missing-texture graceful degradation) --- Tools/ModelTranslator/README.md | 4 +- Tools/ModelTranslator/bl_convert.py | 70 ++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/Tools/ModelTranslator/README.md b/Tools/ModelTranslator/README.md index d283151e..4a01d311 100644 --- a/Tools/ModelTranslator/README.md +++ b/Tools/ModelTranslator/README.md @@ -36,7 +36,8 @@ out/tong/ ## 转换规则 -- 贴图识别:沿材质 Principled BSDF 连线找 Base Color / Alpha / Normal / Metallic / Roughness;AO 按贴图名匹配(含 `ao`/`occlusion`/`ambient`) +- 贴图识别:沿材质 Principled BSDF 连线找 Base Color / Alpha / Normal / Metallic / Roughness;内嵌与外置贴图(FBX 旁的松散文件)均支持 +- AO 按贴图名匹配(含 `ao`/`occlusion`/`ambient`):先找材质内引用的贴图;FBX 未引用时自动搜索 FBX 同目录的图片文件,多候选优先与颜色贴图同前缀(`xxx_color.png` → `xxx_ao.png`) - 基础贴图 A 通道自动判定:**透明贴图 > 颜色贴图自带 alpha > AO 贴图 > 填白(255)**;判定为透明时 `.mat` 自动勾选 `Alpha As Transparency`(AlphaTest 裁剪) - 无对应贴图的通道用 Principled 常量值填充(会在日志中标注 `(常量)`) - 目标尺寸 = min(2048, 源贴图最大边),所有贴图缩放到统一尺寸 @@ -48,6 +49,7 @@ out/tong/ - 法线按 OpenGL 约定(+Y 向上)处理,不提供绿通道翻转 - 不做多材质图集合并/UV 重排;每材质一套贴图 - 动画/骨骼随 FBX 原样导出,不做处理 +- 外置贴图文件丢失时打警告并退化为常量,不中断转换 ## 开发 diff --git a/Tools/ModelTranslator/bl_convert.py b/Tools/ModelTranslator/bl_convert.py index 42f0604e..f0488f3f 100644 --- a/Tools/ModelTranslator/bl_convert.py +++ b/Tools/ModelTranslator/bl_convert.py @@ -36,10 +36,59 @@ def decode_sphere_map(enc01): # ---------------- 以下仅在 Blender 内执行 ---------------- +IMG_EXTS = ('.png', '.jpg', '.jpeg', '.tga', '.exr', '.bmp', '.tif', '.tiff') + + def _looks_like_ao(name): return bool(AO_NAME_RE.search(name)) +def _image_broken(img): + """外置贴图文件丢失/加载失败时,Blender 留下 0 尺寸空图。""" + return img.size[0] == 0 or img.size[1] == 0 + + +def _image_stem(img): + """贴图文件名(不含扩展名);外置贴图优先用 filepath,比 img.name 可靠。""" + path = img.filepath if img.filepath else img.name + return os.path.splitext(os.path.basename(path))[0] + + +def _find_ao_on_disk(src_dir, maps, warnings): + """材质未引用 AO 时,从 FBX 同目录按文件名兜底搜索(烘焙工具常只把 _ao.png 放旁边)。 + 多候选时优先与 basecolor 贴图同前缀(xxx_color.png -> xxx_ao.png)。""" + import bpy + try: + files = sorted(os.listdir(src_dir)) + except OSError: + return None + cands = [f for f in files if f.lower().endswith(IMG_EXTS) + and _looks_like_ao(os.path.splitext(f)[0])] + if not cands: + return None + pick = None + if maps["basecolor"] and len(cands) > 1: + prefix = _image_stem(maps["basecolor"][0]).rsplit("_", 1)[0] + matched = [f for f in cands if f.startswith(prefix)] + if matched: + pick = matched[0] + if pick is None: + if len(cands) == 1: + pick = cands[0] + 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 + + def _image_pixels(img): """bpy Image -> float32 (h, w, 4),原始值(不做色彩变换)。""" w, h = img.size @@ -80,7 +129,7 @@ def _trace_image(socket, warnings, what): return None, None -def _material_maps(mat, warnings): +def _material_maps(mat, warnings, src_dir): """从材质 Principled BSDF 提取贴图与常量。""" maps = {"basecolor": None, "alpha": None, "normal": None, "metallic": None, "roughness": None, "ao": None} @@ -99,7 +148,11 @@ def _material_maps(mat, warnings): return img, ch = _trace_image(sock, warnings, key) if img is not None: - maps[key] = (img, ch) + if _image_broken(img): + warnings.append("%s 贴图 %s 加载失败(外置文件缺失?),改用常量" + % (key, img.name)) + else: + maps[key] = (img, ch) grab('Base Color', 'basecolor') grab('Alpha', 'alpha') @@ -114,9 +167,13 @@ def _material_maps(mat, warnings): used = {m[0] for m in maps.values() if m} for n in mat.node_tree.nodes: if n.type == 'TEX_IMAGE' and n.image and n.image not in used \ - and _looks_like_ao(n.image.name): + and not _image_broken(n.image) and _looks_like_ao(n.image.name): maps["ao"] = (n.image, None) break + if maps["ao"] is None: + img = _find_ao_on_disk(src_dir, maps, warnings) + if img is not None: + maps["ao"] = (img, None) return maps, consts @@ -146,9 +203,9 @@ def _save_png(path, rgba): bpy.data.images.remove(img) -def _convert_material(mat, model_name, outdir, max_size, used_snames): +def _convert_material(mat, model_name, outdir, max_size, used_snames, src_dir): warnings = [] - maps, consts = _material_maps(mat, warnings) + maps, consts = _material_maps(mat, warnings, src_dir) tw, th = _target_size(maps, max_size) # --- 基础贴图 --- @@ -250,8 +307,9 @@ def main(): if slot.material and slot.material not in used_mats: used_mats.append(slot.material) + src_dir = os.path.dirname(os.path.abspath(src)) used_snames = set() - results = [_convert_material(m, model_name, outdir, max_size, used_snames) + results = [_convert_material(m, model_name, outdir, max_size, used_snames, src_dir) for m in used_mats] _strip_textures()