ModelTranslator: Blender conversion pipeline (import, classify, pack, export)
This commit is contained in:
@@ -21,3 +21,235 @@ def decode_sphere_map(enc01):
|
||||
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 内执行 ----------------
|
||||
|
||||
AO_NAME_RE = None # 延迟编译,见 _looks_like_ao
|
||||
|
||||
|
||||
def _looks_like_ao(name):
|
||||
import re
|
||||
global AO_NAME_RE
|
||||
if AO_NAME_RE is None:
|
||||
AO_NAME_RE = re.compile(r"(ao|occlusion|ambient)", re.I)
|
||||
return bool(AO_NAME_RE.search(name))
|
||||
|
||||
|
||||
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):
|
||||
"""从材质 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:
|
||||
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 _looks_like_ao(n.image.name):
|
||||
maps["ao"] = (n.image, None)
|
||||
break
|
||||
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, srgb_view=False):
|
||||
"""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):
|
||||
import os
|
||||
warnings = []
|
||||
maps, consts = _material_maps(mat, warnings)
|
||||
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"]
|
||||
|
||||
import re
|
||||
sname = re.sub(r"[^0-9A-Za-z_\-]", "_", mat.name).strip("_") or "mat"
|
||||
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, os, sys
|
||||
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)
|
||||
|
||||
results = [_convert_material(m, model_name, outdir, max_size) 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 __import__("sys").argv:
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user