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

212 lines
8.2 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 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。
调用:blender -b --factory-startup --python bl_bake.py -- \
<high.fbx> <low.fbx> <outdir> <size> <ray_distance|auto> <samples>
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
DEFAULT_RAY_PCT = 0.02 # 自动射线距离 = 低模包围盒对角线 * 2%
BBOX_TOL = 0.10 # 高低模包围盒尺寸相对差警告阈值
# (输出名, bake type, EMIT 来源的 Principled 输入, 目标图颜色空间)
BAKE_PASSES = [
("normal", 'NORMAL', None, 'Non-Color'),
("ao", 'AO', None, 'Non-Color'),
("color", 'EMIT', 'Base Color', 'sRGB'),
("metallic", 'EMIT', 'Metallic', 'Non-Color'),
("roughness", 'EMIT', 'Roughness', 'Non-Color'),
]
def estimate_ray_distance(bbox_dims, pct=DEFAULT_RAY_PCT):
"""低模包围盒尺寸 (dx,dy,dz) -> 射线距离 = 对角线长 * pct。"""
return math.sqrt(sum(d * d for d in bbox_dims)) * pct
def output_stem(low_name):
"""低模名去掉 _low 后缀作为输出前缀(对齐工具3纯网格贴图命名约定)。"""
return low_name[:-4] if low_name.endswith("_low") else low_name
def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL):
"""任一轴尺寸相对差超 tol 返回 True;接近 0 的轴忽略(平面模型)。"""
for h, l in zip(high_dims, low_dims):
m = max(abs(h), abs(l))
if m > 1e-9 and abs(h - l) / m > tol:
return True
return False
# ---------------- 以下仅在 Blender 内执行 ----------------
def _import_fbx_joined(path, warnings):
"""导入 FBX 并把其中所有 mesh join 成单对象;无 mesh 返回 None。"""
import bpy
from bl_decimate import join_meshes
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=path)
new = [o for o in bpy.data.objects if o not in before]
meshes = [o for o in new if o.type == 'MESH']
skipped = [o.name for o in new if o.type != 'MESH']
if skipped:
warnings.append("%s:忽略非 mesh 对象 %s"
% (os.path.basename(path), ", ".join(skipped)))
if not meshes:
return None
return join_meshes(meshes)
def _setup_bake_target(low, stem):
"""低模换成单一烘焙材质,返回接收烘焙结果的 image 节点。"""
import bpy
mat = bpy.data.materials.new(stem)
mat.use_nodes = True
node = mat.node_tree.nodes.new('ShaderNodeTexImage')
mat.node_tree.nodes.active = node
low.data.materials.clear()
low.data.materials.append(mat)
return mat, node
def _high_materials(high):
return [s.material for s in high.material_slots if s.material]
def _rewire_emit(mat, input_name, warnings):
"""把 Principled 指定输入(连线或常量)改接 Emission 直连输出,供 EMIT 烘焙。"""
import bpy
if not mat.use_nodes:
mat.use_nodes = True
nt = mat.node_tree
bsdf = next((n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED'), None)
out = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL'), None)
if out is None:
out = nt.nodes.new('ShaderNodeOutputMaterial')
emit = nt.nodes.get("mt_emit")
if emit is None:
emit = nt.nodes.new('ShaderNodeEmission')
emit.name = "mt_emit"
for l in list(emit.inputs['Color'].links):
nt.links.remove(l)
if bsdf is None:
warnings.append("材质 %s 无 Principled BSDF%s 用 0.5 灰常量"
% (mat.name, input_name))
emit.inputs['Color'].default_value = (0.5, 0.5, 0.5, 1.0)
else:
sock = bsdf.inputs[input_name]
if sock.is_linked:
nt.links.new(sock.links[0].from_socket, emit.inputs['Color'])
else:
v = sock.default_value
if isinstance(v, float):
# 该通道无贴图,把常量值直接烘入
warnings.append("%s.%s 无贴图,(常量) %.3f 烘入" % (mat.name, input_name, v))
emit.inputs['Color'].default_value = (v, v, v, 1.0)
else: # Base Color 是 4 分量
# 该通道无贴图,把常量色直接烘入
warnings.append("%s.%s 无贴图,(常量色 %.3f/%.3f/%.3f) 烘入"
% (mat.name, input_name, v[0], v[1], v[2]))
emit.inputs['Color'].default_value = (v[0], v[1], v[2], 1.0)
for l in list(out.inputs['Surface'].links):
nt.links.remove(l)
nt.links.new(emit.outputs['Emission'], out.inputs['Surface'])
def _bake_pass(low, high, target_node, name, bake_type, size, colorspace,
ray, samples, outdir, stem):
"""执行一个烘焙 pass 并保存 PNG,返回文件名。"""
import bpy
img = bpy.data.images.new("mt_bake_" + name, size, size, alpha=False)
img.colorspace_settings.name = colorspace
target_node.image = img
bpy.ops.object.select_all(action='DESELECT')
high.select_set(True)
low.select_set(True)
bpy.context.view_layer.objects.active = low
bpy.context.scene.cycles.samples = samples
kwargs = dict(type=bake_type, use_selected_to_active=True,
cage_extrusion=ray, max_ray_distance=ray * 2.0,
margin=16, use_clear=True)
if bake_type == 'NORMAL':
kwargs["normal_space"] = 'TANGENT' # OpenGL +Y
bpy.ops.object.bake(**kwargs)
path = os.path.abspath(os.path.join(outdir, "%s_%s.png" % (stem, name)))
img.filepath_raw = path
img.file_format = 'PNG'
img.save()
bpy.data.images.remove(img)
return os.path.basename(path)
def main():
import bpy
import json
argv = sys.argv[sys.argv.index("--") + 1:]
high_fbx, low_fbx, outdir = argv[0], argv[1], argv[2]
size, ray_arg, ao_samples = int(argv[3]), argv[4], int(argv[5])
warnings = []
bpy.ops.wm.read_factory_settings(use_empty=True)
high = _import_fbx_joined(high_fbx, warnings)
low = _import_fbx_joined(low_fbx, warnings)
if high is None or low is None:
print("MT_SUMMARY " + json.dumps(
{"error": "高模或低模 FBX 中没有 mesh"}, ensure_ascii=False))
return
if not low.data.uv_layers:
print("MT_SUMMARY " + json.dumps(
{"error": "低模没有 UV 层,请先用 model_decimate.py 生成"},
ensure_ascii=False))
return
high_dims, low_dims = tuple(high.dimensions), tuple(low.dimensions)
if bbox_mismatch(high_dims, low_dims):
warnings.append("高低模包围盒尺寸差异超 %d%%:高 %s%s,确认是同一模型且坐标已对齐"
% (BBOX_TOL * 100,
[round(d, 3) for d in high_dims],
[round(d, 3) for d in low_dims]))
ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)
stem = output_stem(os.path.splitext(os.path.basename(low_fbx))[0])
os.makedirs(outdir, exist_ok=True)
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
bake_mat, target_node = _setup_bake_target(low, stem)
mats = _high_materials(high)
outputs = {}
for name, bake_type, emit_input, colorspace in BAKE_PASSES:
if emit_input is not None: # EMIT trick:先改写全部高模材质
for m in mats:
_rewire_emit(m, emit_input, warnings)
samples = ao_samples if bake_type == 'AO' else 1
outputs[name] = _bake_pass(low, high, target_node, name, bake_type,
size, colorspace, ray, samples, outdir, stem)
# 导出低模:剥掉烘焙 image 节点,材质名保留 stem(工具3 兜底识别用)
bake_mat.node_tree.nodes.remove(target_node)
out_fbx = os.path.join(outdir, stem + ".fbx")
bpy.ops.object.select_all(action='DESELECT')
low.select_set(True)
bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True,
path_mode='STRIP', embed_textures=False)
print("MT_SUMMARY " + json.dumps(
{"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
"size": size, "ray_distance": round(ray, 6),
"high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons),
"warnings": warnings}, ensure_ascii=False))
if __name__ == "__main__" and "--" in sys.argv:
main()