ModelTranslator: 高低模烘焙 Blender 主流程(NORMAL/AO + EMIT trick)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ud18010
2026-07-16 10:47:24 +08:00
co-authored by Claude Opus 4.8
parent 7c3b86a995
commit eed388770b
+166
View File
@@ -38,3 +38,169 @@ def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL):
if m > 1e-9 and abs(h - l) / m > tol: if m > 1e-9 and abs(h - l) / m > tol:
return True return True
return False 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):
emit.inputs['Color'].default_value = (v, v, v, 1.0)
else: # Base Color 是 4 分量
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()