ModelTranslator: support external-texture FBX (disk AO fallback search, missing-texture graceful degradation)
This commit is contained in:
@@ -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 裁剪)
|
- 基础贴图 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 原样导出,不做处理
|
||||||
|
- 外置贴图文件丢失时打警告并退化为常量,不中断转换
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +148,10 @@ 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:
|
||||||
|
if _image_broken(img):
|
||||||
|
warnings.append("%s 贴图 %s 加载失败(外置文件缺失?),改用常量"
|
||||||
|
% (key, img.name))
|
||||||
|
else:
|
||||||
maps[key] = (img, ch)
|
maps[key] = (img, ch)
|
||||||
|
|
||||||
grab('Base Color', 'basecolor')
|
grab('Base Color', 'basecolor')
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user