ModelTranslator: support external-texture FBX (disk AO fallback search, missing-texture graceful degradation)

This commit is contained in:
ud18010
2026-07-15 12:16:24 +08:00
parent 0b068dbaa0
commit 78b250355b
2 changed files with 67 additions and 7 deletions
+3 -1
View File
@@ -36,7 +36,8 @@ out/tong/
## 转换规则 ## 转换规则
- 贴图识别:沿材质 Principled BSDF 连线找 Base Color / Alpha / Normal / Metallic / RoughnessAO 按贴图名匹配(含 `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 裁剪) - 基础贴图 A 通道自动判定:**透明贴图 > 颜色贴图自带 alpha > AO 贴图 > 填白(255)**;判定为透明时 `.mat` 自动勾选 `Alpha As Transparency`AlphaTest 裁剪)
- 无对应贴图的通道用 Principled 常量值填充(会在日志中标注 `(常量)` - 无对应贴图的通道用 Principled 常量值填充(会在日志中标注 `(常量)`
- 目标尺寸 = min(2048, 源贴图最大边),所有贴图缩放到统一尺寸 - 目标尺寸 = min(2048, 源贴图最大边),所有贴图缩放到统一尺寸
@@ -48,6 +49,7 @@ out/tong/
- 法线按 OpenGL 约定(+Y 向上)处理,不提供绿通道翻转 - 法线按 OpenGL 约定(+Y 向上)处理,不提供绿通道翻转
- 不做多材质图集合并/UV 重排;每材质一套贴图 - 不做多材质图集合并/UV 重排;每材质一套贴图
- 动画/骨骼随 FBX 原样导出,不做处理 - 动画/骨骼随 FBX 原样导出,不做处理
- 外置贴图文件丢失时打警告并退化为常量,不中断转换
## 开发 ## 开发
+64 -6
View File
@@ -36,10 +36,59 @@ def decode_sphere_map(enc01):
# ---------------- 以下仅在 Blender 内执行 ---------------- # ---------------- 以下仅在 Blender 内执行 ----------------
IMG_EXTS = ('.png', '.jpg', '.jpeg', '.tga', '.exr', '.bmp', '.tif', '.tiff')
def _looks_like_ao(name): def _looks_like_ao(name):
return bool(AO_NAME_RE.search(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): def _image_pixels(img):
"""bpy Image -> float32 (h, w, 4),原始值(不做色彩变换)。""" """bpy Image -> float32 (h, w, 4),原始值(不做色彩变换)。"""
w, h = img.size w, h = img.size
@@ -80,7 +129,7 @@ def _trace_image(socket, warnings, what):
return None, None return None, None
def _material_maps(mat, warnings): def _material_maps(mat, warnings, src_dir):
"""从材质 Principled BSDF 提取贴图与常量。""" """从材质 Principled BSDF 提取贴图与常量。"""
maps = {"basecolor": None, "alpha": None, "normal": None, maps = {"basecolor": None, "alpha": None, "normal": None,
"metallic": None, "roughness": None, "ao": None} "metallic": None, "roughness": None, "ao": None}
@@ -99,7 +148,11 @@ def _material_maps(mat, warnings):
return return
img, ch = _trace_image(sock, warnings, key) img, ch = _trace_image(sock, warnings, key)
if img is not None: 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('Base Color', 'basecolor')
grab('Alpha', 'alpha') grab('Alpha', 'alpha')
@@ -114,9 +167,13 @@ def _material_maps(mat, warnings):
used = {m[0] for m in maps.values() if m} used = {m[0] for m in maps.values() if m}
for n in mat.node_tree.nodes: for n in mat.node_tree.nodes:
if n.type == 'TEX_IMAGE' and n.image and n.image not in used \ 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) maps["ao"] = (n.image, None)
break 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 return maps, consts
@@ -146,9 +203,9 @@ def _save_png(path, rgba):
bpy.data.images.remove(img) 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 = [] warnings = []
maps, consts = _material_maps(mat, warnings) maps, consts = _material_maps(mat, warnings, src_dir)
tw, th = _target_size(maps, max_size) tw, th = _target_size(maps, max_size)
# --- 基础贴图 --- # --- 基础贴图 ---
@@ -250,8 +307,9 @@ def main():
if slot.material and slot.material not in used_mats: if slot.material and slot.material not in used_mats:
used_mats.append(slot.material) used_mats.append(slot.material)
src_dir = os.path.dirname(os.path.abspath(src))
used_snames = set() 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] for m in used_mats]
_strip_textures() _strip_textures()