ModelTranslator: support pure-mesh FBX via naming-convention texture discovery and auto material
This commit is contained in:
@@ -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 同目录识别整套贴图——`<前缀>_<token>.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, 源贴图最大边),所有贴图缩放到统一尺寸
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user