"""Blender 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。 调用:blender -b --factory-startup --python bl_bake.py -- \ """ 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): 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()