Files
AIC-Project/Tools/ModelTranslator/bl_convert.py
T
ud18010andClaude Opus 4.8 60c83a3765 ModelTranslator: base 贴图写 PNG 前做 linear->sRGB 编码
Blender Image.pixels 对 sRGB 贴图返回线性值,直接写 Non-Color PNG 会偏暗;
现按 Unity sRGB 导入预期编码 RGB,alpha(AO/透明)保持原值。附单元测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 20:28:33 +08:00

405 lines
15 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)
def linear_to_srgb(c):
c = np.clip(c, 0.0, 1.0)
return np.where(c <= 0.0031308, c * 12.92,
1.055 * np.power(c, 1.0 / 2.4) - 0.055)
def encode_base_texture_for_png(rgba):
"""Base RGB is stored for Unity sRGB import; alpha keeps AO/opacity data."""
out = np.array(rgba, dtype=np.float32, copy=True)
out[..., :3] = linear_to_srgb(out[..., :3])
out[..., 3] = np.clip(out[..., 3], 0.0, 1.0)
return out
# ---------------- 以下仅在 Blender 内执行 ----------------
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))
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 _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)。"""
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
return _load_disk_image(src_dir, pick, warnings)
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, model_name):
"""从材质 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
# 纯网格 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:
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, model_name)
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))
# 材质名与模型名相同(如纯网格 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, encode_base_texture_for_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)
# 纯网格 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':
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()