From eed388770bc0cf9cadc3ef1e9aa698450975b8d2 Mon Sep 17 00:00:00 2001 From: ud18010 Date: Thu, 16 Jul 2026 10:47:24 +0800 Subject: [PATCH] =?UTF-8?q?ModelTranslator:=20=E9=AB=98=E4=BD=8E=E6=A8=A1?= =?UTF-8?q?=E7=83=98=E7=84=99=20Blender=20=E4=B8=BB=E6=B5=81=E7=A8=8B?= =?UTF-8?q?=EF=BC=88NORMAL/AO=20+=20EMIT=20trick=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- Tools/ModelTranslator/bl_bake.py | 166 +++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/Tools/ModelTranslator/bl_bake.py b/Tools/ModelTranslator/bl_bake.py index 88e1f50e..5ee7743b 100644 --- a/Tools/ModelTranslator/bl_bake.py +++ b/Tools/ModelTranslator/bl_bake.py @@ -38,3 +38,169 @@ def bbox_mismatch(high_dims, low_dims, tol=BBOX_TOL): 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): + 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()