Files
AIC-Project/Tools/ModelTranslator/bl_convert.py
T

326 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Blender 内运行:FBX -> 纯模型 FBX + XPbr 基础/混合贴图。
调用:blender -b --factory-startup --python bl_convert.py -- <src.fbx> <outdir> <max_size>
"""
import os
import re
import sys
import numpy as np
# blender --python 不会把脚本目录加入 sys.path,手动加入以复用 unity_assets(纯 stdlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from unity_assets import safe_name
AO_NAME_RE = re.compile(r"(ao|occlusion|ambient)", re.I)
def encode_normal_rg(n):
"""切线法线 (N,3) -> 球面编码 (N,2),范围 [0,1]。
shader 侧 XPbr_Base.hlsl DecodeSphereMap 先 *2-1 再解码。"""
n = n / np.maximum(np.linalg.norm(n, axis=-1, keepdims=True), 1e-8)
z = np.clip(n[..., 2], -0.99, 1.0)
denom = np.sqrt(2.0 * (1.0 + z))
enc = n[..., :2] / denom[..., None]
return np.clip(enc * 0.5 + 0.5, 0.0, 1.0)
def decode_sphere_map(enc01):
"""shader DecodeSphereMap 的 numpy 等价(含 *2-1),仅测试用。"""
e = enc01 * 2.0 - 1.0
l = 1.0 - e[..., 0] ** 2 - e[..., 1] ** 2
l = np.clip(l, 0.0, 1.0)
xy = e * np.sqrt(l)[..., None]
return np.concatenate([xy * 2.0, (l * 2.0 - 1.0)[..., None]], axis=-1)
# ---------------- 以下仅在 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
buf = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(buf)
return buf.reshape(h, w, 4)
def _scaled_pixels(img, tw, th):
"""取缩放到 (tw, th) 的像素;用副本 scale,不污染原图。"""
if tuple(img.size) == (tw, th):
return _image_pixels(img)
cp = img.copy()
try:
cp.scale(tw, th)
return _image_pixels(cp)
finally:
import bpy
bpy.data.images.remove(cp)
def _trace_image(socket, warnings, what):
"""沿输入连线找贴图。返回 (bpy.types.Image | None, channel | None)。
穿透 Normal Map / Separate Color 节点;channel 为 0..3 或 None(用 R/灰度)。"""
if not socket.is_linked:
return None, None
node = socket.links[0].from_node
out_name = socket.links[0].from_socket.name
if node.type == 'TEX_IMAGE':
return node.image, None
if node.type == 'NORMAL_MAP':
return _trace_image(node.inputs['Color'], warnings, what)
if node.type in ('SEPARATE_COLOR', 'SEPRGB'):
ch = {'Red': 0, 'Green': 1, 'Blue': 2, 'R': 0, 'G': 1, 'B': 2}.get(out_name, 0)
img, _ = _trace_image(node.inputs[0], warnings, what)
return img, ch
warnings.append("%s: 无法识别的节点 %s,改用常量" % (what, node.type))
return None, None
def _material_maps(mat, warnings, src_dir):
"""从材质 Principled BSDF 提取贴图与常量。"""
maps = {"basecolor": None, "alpha": None, "normal": None,
"metallic": None, "roughness": None, "ao": None}
consts = {"basecolor": (0.5, 0.5, 0.5, 1.0), "metallic": 0.0, "roughness": 0.5}
if not mat.use_nodes:
warnings.append("材质未用节点,全部用默认常量")
return maps, consts
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
if bsdf is None:
warnings.append("未找到 Principled BSDF,全部用默认常量")
return maps, consts
def grab(input_name, key):
sock = bsdf.inputs.get(input_name)
if sock is None:
return
img, ch = _trace_image(sock, warnings, key)
if img is not None:
if _image_broken(img):
warnings.append("%s 贴图 %s 加载失败(外置文件缺失?),改用常量"
% (key, img.name))
else:
maps[key] = (img, ch)
grab('Base Color', 'basecolor')
grab('Alpha', 'alpha')
grab('Normal', 'normal')
grab('Metallic', 'metallic')
grab('Roughness', 'roughness')
consts["basecolor"] = tuple(bsdf.inputs['Base Color'].default_value)
consts["metallic"] = float(bsdf.inputs['Metallic'].default_value)
consts["roughness"] = float(bsdf.inputs['Roughness'].default_value)
# AO:材质节点树内按名匹配(未连到 BSDF 也算)
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 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
def _target_size(maps, max_size):
ws = [m[0].size[0] for m in maps.values() if m]
hs = [m[0].size[1] for m in maps.values() if m]
if not ws:
return 4, 4
return min(max(ws), max_size), min(max(hs), max_size)
def _channel(pixels, ch):
return pixels[..., ch if ch is not None else 0]
def _save_png(path, rgba):
"""float32 (h,w,4) -> PNG。写原始值(Non-Color,避免色彩管理改写数据)。"""
import bpy
h, w = rgba.shape[:2]
img = bpy.data.images.new("mt_tmp", width=w, height=h, alpha=True)
img.colorspace_settings.name = 'Non-Color'
img.pixels.foreach_set(np.ascontiguousarray(rgba, dtype=np.float32).ravel())
img.filepath_raw = path
img.file_format = 'PNG'
img.alpha_mode = 'CHANNEL_PACKED'
img.save()
bpy.data.images.remove(img)
def _convert_material(mat, model_name, outdir, max_size, used_snames, src_dir):
warnings = []
maps, consts = _material_maps(mat, warnings, src_dir)
tw, th = _target_size(maps, max_size)
# --- 基础贴图 ---
base = np.empty((th, tw, 4), dtype=np.float32)
if maps["basecolor"]:
base[..., :3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., :3]
else:
base[..., :3] = np.array(consts["basecolor"][:3], dtype=np.float32)
if maps["alpha"]:
img, ch = maps["alpha"]
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
alpha_mode = "transparency"
elif maps["basecolor"] and maps["basecolor"][0].channels == 4 and \
_image_has_alpha(maps["basecolor"][0]):
base[..., 3] = _scaled_pixels(maps["basecolor"][0], tw, th)[..., 3]
alpha_mode = "transparency"
elif maps["ao"]:
img, ch = maps["ao"]
base[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
alpha_mode = "ao"
else:
base[..., 3] = 1.0
alpha_mode = "white"
# --- 混合贴图 ---
mix = np.empty((th, tw, 4), dtype=np.float32)
if maps["normal"]:
npx = _scaled_pixels(maps["normal"][0], tw, th)[..., :3] * 2.0 - 1.0
mix[..., 0:2] = encode_normal_rg(npx)
else:
mix[..., 0:2] = 0.5
warnings.append("无法线贴图,RG 填平面法线")
if maps["metallic"]:
img, ch = maps["metallic"]
mix[..., 2] = _channel(_scaled_pixels(img, tw, th), ch)
else:
mix[..., 2] = consts["metallic"]
if maps["roughness"]:
img, ch = maps["roughness"]
mix[..., 3] = _channel(_scaled_pixels(img, tw, th), ch)
else:
mix[..., 3] = consts["roughness"]
# 中文等非 ASCII 材质名会同样退化(如都变 "mat"),加序号防止互相覆盖
sname = base_sname = safe_name(mat.name)
n = 1
while sname in used_snames:
n += 1
sname = "%s_%d" % (base_sname, n)
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)))
_save_png(base_png, base)
_save_png(mix_png, mix)
return {
"name": mat.name, "safe_name": sname,
"base_png": os.path.basename(base_png), "mix_png": os.path.basename(mix_png),
"alpha_mode": alpha_mode, "size": [tw, th],
"sources": {k: (v[0].name if v else None) for k, v in maps.items()},
"warnings": warnings,
}
def _image_has_alpha(img):
"""采样判断 alpha 是否非全白(全白视为无透明信息)。"""
px = _image_pixels(img)
a = px[..., 3]
return bool((a < 0.995).any())
def _strip_textures():
import bpy
for mat in bpy.data.materials:
if not mat.use_nodes:
continue
for n in list(mat.node_tree.nodes):
if n.type in ('TEX_IMAGE', 'NORMAL_MAP', 'SEPARATE_COLOR', 'SEPRGB'):
mat.node_tree.nodes.remove(n)
def main():
import bpy, json
argv = sys.argv[sys.argv.index("--") + 1:]
src, outdir, max_size = argv[0], argv[1], int(argv[2])
os.makedirs(outdir, exist_ok=True)
model_name = os.path.splitext(os.path.basename(src))[0]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=src)
used_mats = []
for obj in bpy.data.objects:
if obj.type != 'MESH':
continue
for slot in obj.material_slots:
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, src_dir)
for m in used_mats]
_strip_textures()
fbx_out = os.path.join(outdir, model_name + ".fbx")
bpy.ops.export_scene.fbx(filepath=fbx_out, path_mode='STRIP', embed_textures=False)
print("MT_SUMMARY " + json.dumps(
{"model": model_name, "fbx": os.path.basename(fbx_out), "materials": results},
ensure_ascii=False))
if __name__ == "__main__" and "--" in sys.argv:
main()