"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。 调用:blender -b --factory-startup --python bl_decimate.py -- [seam|smart] """ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) TOLERANCE = 0.03 # 面数相对误差容忍 MAX_RETRY = 2 # ratio 修正轮数上限 def decimate_ratio(target, cur): """Decimate collapse ratio;cur <= target 或非法时返回 1.0(不减面)。""" if cur <= 0 or cur <= target: return 1.0 return target / float(cur) def within_tolerance(target, actual, tol=TOLERANCE): """减面结果是否落在目标 ±tol 内。""" if target <= 0: return False return abs(actual - target) <= target * tol UV_FLIP_TOL = 0.02 # 质量门:UV 翻转面占比超此值回退 smart_project UV_OVERLAP_TOL = 0.08 # 质量门:UV 重叠面占比阈值(手工 UV 同口径约 5%,灾难性失败 >20%) def uv_gate_ok(flipped, overlap, overlap_tol=UV_OVERLAP_TOL): """UV 质量门:翻转按 UV_FLIP_TOL 固定,重叠按 overlap_tol(默认 UV_OVERLAP_TOL)。 overlap 为 None(op 不可用)按 0 处理。""" return flipped <= UV_FLIP_TOL and (overlap or 0.0) <= overlap_tol def uvpm_mode_label(base, applied): """UV 模式标注:UVPackmaster 排布生效时加 +uvpm 后缀。""" return base + "+uvpm" if applied else base def pick_best_candidate(candidates, overlap_tol=UV_OVERLAP_TOL): """从候选 UV 指标 dict 列表选过质量门且岛数最少者;无过门候选返回 None。 overlap_tol 覆盖重叠容忍。每个 candidate 至少含 flipped/overlap/islands。""" passing = [c for c in candidates if uv_gate_ok(c["flipped"], c["overlap"], overlap_tol)] if not passing: return None return min(passing, key=lambda c: c["islands"]) def count_islands(n_faces, adjacent_pairs): """union-find 数 UV 岛:n_faces 个面,adjacent_pairs 为 UV 连续的面索引对。""" parent = list(range(n_faces)) def find(i): while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i for a, b in adjacent_pairs: ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb return len({find(i) for i in range(n_faces)}) def flipped_fraction(signed_areas): """UV 面签名面积为负(翻转)的占比。""" if not signed_areas: return 0.0 return sum(1 for a in signed_areas if a < 0) / float(len(signed_areas)) def fill_ratio(abs_areas): """UV 空间利用率:面积和(重叠会虚高,截断到 1.0)。""" return min(1.0, sum(abs_areas)) # ---------------- 以下仅在 Blender 内执行 ---------------- def join_meshes(objs): """多 mesh join 成单对象,返回结果对象(bl_bake 复用)。""" import bpy bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] if len(objs) > 1: bpy.ops.object.join() return bpy.context.view_layer.objects.active def _apply_modifier(obj, mod): import bpy bpy.context.view_layer.objects.active = obj bpy.ops.object.modifier_apply(modifier=mod.name) def _triangulate(obj): mod = obj.modifiers.new("mt_tri", 'TRIANGULATE') _apply_modifier(obj, mod) def _decimate(obj, ratio): mod = obj.modifiers.new("mt_dec", 'DECIMATE') mod.decimate_type = 'COLLAPSE' mod.ratio = ratio mod.use_collapse_triangulate = True _apply_modifier(obj, mod) def _clean_mesh(obj, warnings): """减面前清理:按局部包围盒对角线相对焊接重合点 + 去退化面,给 collapse 更干净的流形, 减少被迫加的 seam。各步失败降级不中断。""" import bpy import mathutils bb = [mathutils.Vector(c) for c in obj.bound_box] diag = (bb[0] - bb[6]).length bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') if diag > 0.0: try: bpy.ops.mesh.remove_doubles(threshold=diag * REL_WELD) except RuntimeError as e: warnings.append("remove_doubles 失败(%s),跳过焊接" % e) else: warnings.append("包围盒对角线为 0,跳过焊接") try: bpy.ops.mesh.dissolve_degenerate() except RuntimeError as e: warnings.append("dissolve_degenerate 失败(%s),跳过去退化" % e) bpy.ops.object.mode_set(mode='OBJECT') SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px UVPM_EXT = "bl_ext.user_default.uvpackmaster4" # UVPackmaster 4 扩展模块名 UVPM_PIXEL_MARGIN = 4 # UVPM 岛间距(像素),与 PACK_MARGIN * UVPM_TEX_SIZE 同口径 UVPM_TEX_SIZE = 2048 UVPM_ROTATION_STEP = 15 # UVPM 旋转步进(度);比默认 90 更细,利于薄斜条对齐(启发式因引擎崩溃已弃用) SEAM_ANGLE_SWEEP = (55.0, 45.0, 35.0) # 首选 SEAM_ANGLE_DEG 不过门时依次下探的 seam 角度 STRETCH_ITERS = 30 # minimize_stretch 松弛迭代次数 REL_WELD = 1e-4 # 焊接距离占局部包围盒对角线比例(避免 cm/m 单位差异导致绝对阈值失准) def _clear_uv_layers(mesh): while mesh.uv_layers: mesh.uv_layers.remove(mesh.uv_layers[0]) def _unwrap_smart(obj): """Smart UV Project(原路径,也是质量门的回退路径)。""" import bpy import math _clear_uv_layers(obj.data) bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=PACK_MARGIN) bpy.ops.object.mode_set(mode='OBJECT') def _unwrap_seam(obj, warnings, seam_angle_deg=SEAM_ANGLE_DEG): """锐边标 seam -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> 松弛 -> 纹素均衡 -> pack。 seam_angle_deg 可变以支持角度扫描;重跑前清掉上一档残留 seam。""" import bpy import math _clear_uv_layers(obj.data) obj.data.uv_layers.new() bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_mode(type='EDGE') # 清掉上一档角度残留 seam(同一对象多角度扫描时必须;同时丢弃源资产自带 seam,保证确定性) bpy.ops.mesh.select_all(action='SELECT') bpy.ops.mesh.mark_seam(clear=True) # 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛 bpy.ops.mesh.select_all(action='DESELECT') bpy.ops.mesh.edges_select_sharp(sharpness=math.radians(seam_angle_deg)) bpy.ops.mesh.mark_seam(clear=False) bpy.ops.mesh.select_all(action='SELECT') try: bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=PACK_MARGIN) except TypeError: # 老版本无 SLIM 枚举 warnings.append("MINIMUM_STRETCH 不可用,展开改用 ANGLE_BASED") bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=PACK_MARGIN) try: # 角度松弛:压翻转/拉伸 bpy.ops.uv.minimize_stretch(iterations=STRETCH_ITERS) except (RuntimeError, TypeError) as e: warnings.append("minimize_stretch 不可用(%s),跳过松弛" % e) try: # 统一纹素密度,避免大岛霸占分辨率 bpy.ops.uv.average_islands_scale() except (RuntimeError, TypeError) as e: warnings.append("average_islands_scale 不可用(%s),跳过纹素均衡" % e) try: bpy.ops.uv.pack_islands(rotate=True, margin=PACK_MARGIN) except RuntimeError: # headless 上下文不满足时靠 unwrap 自带打包 warnings.append("pack_islands 不可用,沿用 unwrap 自带布局") bpy.ops.object.mode_set(mode='OBJECT') def _collect_uv_metrics(obj): """bmesh 提取 UV 指标:岛数(UV 连续边并查集)、利用率、翻转占比、重叠占比。""" import bmesh bm = bmesh.new() bm.from_mesh(obj.data) uv = bm.loops.layers.uv.active pairs = [] for e in bm.edges: if len(e.link_faces) != 2: continue f1, f2 = e.link_faces cont = True for v in e.verts: # 两面在该边两端 UV 一致 => UV 连续(同岛) l1 = next(l for l in f1.loops if l.vert == v) l2 = next(l for l in f2.loops if l.vert == v) if (l1[uv].uv - l2[uv].uv).length > 1e-6: cont = False break if cont: pairs.append((f1.index, f2.index)) signed = [] for f in bm.faces: area = 0.0 loops = f.loops for i in range(len(loops)): a = loops[i][uv].uv b = loops[(i + 1) % len(loops)][uv].uv area += a.x * b.y - b.x * a.y signed.append(area * 0.5) n = len(bm.faces) bm.free() return {"islands": count_islands(n, pairs), "fill": round(fill_ratio([abs(a) for a in signed]), 4), "flipped": round(flipped_fraction(signed), 4), "overlap": _overlap_fraction(obj)} def _overlap_fraction(obj): """uv.select_overlap 统计重叠 UV 面占比;op 上下文不可用返回 None。""" import bpy bpy.context.scene.tool_settings.use_uv_select_sync = True bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='DESELECT') try: bpy.ops.uv.select_overlap() except RuntimeError: return None finally: bpy.ops.object.mode_set(mode='OBJECT') polys = obj.data.polygons if not len(polys): return 0.0 return round(sum(1 for p in polys if p.select) / float(len(polys)), 4) _uvpm_failed = False # 进程内失败一次即不再重试(避免重复警告与启动开销) def _uvpm_enable(): """启用 UVPM4 扩展(幂等)。headless 下先补丁 GPU shader 创建—— UVPM4 导入期为视口覆盖层建 shader,后台模式无 GPU 绘图会 SystemError; 覆盖层仅交互用,pack 不受影响。引擎路径经注册表自动发现。""" import bpy import addon_utils if bpy.app.background: import gpu orig = gpu.shader.from_builtin if not getattr(orig, "_mt_safe", False): def _safe(*a, **k): try: return orig(*a, **k) except SystemError: return None _safe._mt_safe = True gpu.shader.from_builtin = _safe # default_set=True 才建 preferences.addons 条目(UVPM4 register 依赖); # factory-startup 关闭偏好自动保存,不会写盘 if addon_utils.enable(UVPM_EXT, default_set=True) is None: raise RuntimeError("扩展 %s 启用失败(未安装或版本不兼容)" % UVPM_EXT) def _uvpm_set(p, name, value, warnings): """防御式设 UVPM 属性:属性存在才设,否则记警告跳过—— 名字不匹配(UVPM 版本差异)时降级为按原 margin 跑 UVPM,不丢整个排布。""" if hasattr(p, name): try: setattr(p, name, value) except Exception as e: warnings.append("UVPM 属性 %s 设置失败(%s),跳过" % (name, e)) else: warnings.append("UVPM 属性 %s 不存在,跳过" % name) def _uvpm_repack(obj, warnings): """UVPM4 重排当前 UV 布局,成功返回 True;任何失败记警告返回 False, 保底布局(pack_islands/smart_project)原样保留。rotation_step 调细以利薄斜条对齐。 注:UVPM 启发式搜索(heuristic)在 headless 下会随机崩溃引擎进程(Engine process died), 确定性/稳定性差,故不启用——只用旋转增强这一稳定杠杆。""" global _uvpm_failed if _uvpm_failed: return False import bpy try: _uvpm_enable() p = bpy.context.scene.uvpm4_props.default_main_props p.pixel_margin_enable = True p.pixel_margin = UVPM_PIXEL_MARGIN p.pixel_margin_tex_size = UVPM_TEX_SIZE _uvpm_set(p, "rotation_enable", True, warnings) _uvpm_set(p, "rotation_step", UVPM_ROTATION_STEP, warnings) bpy.context.scene.tool_settings.use_uv_select_sync = True bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') try: ret = bpy.ops.uvpackmaster4.pack(mode_id='pack.single_tile', pack_op_type='0') finally: bpy.ops.object.mode_set(mode='OBJECT') if 'FINISHED' not in ret: raise RuntimeError("pack 返回 %s" % sorted(ret)) return True except Exception as e: _uvpm_failed = True warnings.append("UVPackmaster 排布不可用(%s),沿用内置 pack 布局" % e) return False def _do_unwrap(obj, mode, warnings, overlap_tol=UV_OVERLAP_TOL): """按模式展开:seam 从 SEAM_ANGLE_DEG 起降序扫 SEAM_ANGLE_SWEEP,首个过门者即选并停扫 ——岛数随角度降单调增,故首个过门者已是过门候选里岛数最少的(pick_best_candidate 据此在 候选集取岛数最少者,与早停一致;重跑分支为防御:早停下 best 恒为最后一档,通常不触发); 全失败退 smart。overlap_tol 覆盖重叠门限(翻转仍严格)。排布 UVPM4 增强,只在最终 UV 上跑一次,失败保底内置 pack。返回 uv 指标 dict(含 mode)。""" if mode == "seam": angles = [SEAM_ANGLE_DEG] + list(SEAM_ANGLE_SWEEP) candidates = [] for ang in angles: _unwrap_seam(obj, warnings, seam_angle_deg=ang) m = _collect_uv_metrics(obj) m["angle"] = ang candidates.append(m) if uv_gate_ok(m["flipped"], m["overlap"], overlap_tol): break # 该档已过门;更低角度只会更碎,无需再试 best = pick_best_candidate(candidates, overlap_tol) if best is not None: if best["angle"] != candidates[-1]["angle"]: # 选中档不是最后跑的那档,重跑恢复其 UV(_unwrap_seam 会覆盖) _unwrap_seam(obj, warnings, seam_angle_deg=best["angle"]) uvpm = _uvpm_repack(obj, warnings) m = _collect_uv_metrics(obj) m["mode"] = uvpm_mode_label("seam@%d" % int(best["angle"]), uvpm) return m detail = "、".join( "%d°(翻转%.1f%% 重叠%s)" % ( int(c["angle"]), c["flipped"] * 100, "%.1f%%" % (c["overlap"] * 100) if c["overlap"] is not None else "未知") for c in candidates) warnings.append("所有 seam 角度均未过质量门(%s),回退 smart_project" % detail) mode = "smart_fallback" _unwrap_smart(obj) uvpm = _uvpm_repack(obj, warnings) m = _collect_uv_metrics(obj) m["mode"] = uvpm_mode_label(mode if mode == "smart_fallback" else "smart", uvpm) return m def _collect_uv_polygons(obj): """提取每个面的 UV 多边形(归一化坐标):[[[u,v], ...], ...],供外部 PIL 绘图。 无 UV 层返回空列表。""" import bmesh bm = bmesh.new() bm.from_mesh(obj.data) uv = bm.loops.layers.uv.active polys = [] if uv is not None: for f in bm.faces: polys.append([[l[uv].uv.x, l[uv].uv.y] for l in f.loops]) bm.free() return polys def main(): import bpy import json argv = sys.argv[sys.argv.index("--") + 1:] src, out_fbx, target = argv[0], argv[1], int(argv[2]) unwrap_mode = argv[3] if len(argv) > 3 else "seam" overlap_tol = float(argv[4]) if len(argv) > 4 and argv[4] else UV_OVERLAP_TOL warnings = [] bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.import_scene.fbx(filepath=src) meshes = [o for o in bpy.data.objects if o.type == 'MESH'] skipped = [o.name for o in bpy.data.objects if o.type != 'MESH'] if skipped: warnings.append("忽略非 mesh 对象:%s(本工具只处理静态网格)" % ", ".join(skipped)) if not meshes: print("MT_SUMMARY " + json.dumps({"error": "FBX 中没有 mesh"}, ensure_ascii=False)) return obj = join_meshes(meshes) _clean_mesh(obj, warnings) _triangulate(obj) orig = len(obj.data.polygons) cur = orig if cur <= target: warnings.append("当前 %d 面 <= 目标 %d,跳过减面" % (cur, target)) else: # collapse 结果是近似值;未达容差时按新面数修正 ratio 再来,最多 MAX_RETRY 轮 for _ in range(1 + MAX_RETRY): _decimate(obj, decimate_ratio(target, cur)) _triangulate(obj) # collapse triangulate 后仍可能残留非三角面 cur = len(obj.data.polygons) if within_tolerance(target, cur) or cur <= target: break # 重试用尽仍超差且偏多:记录警告,交由上层决定是否可接受 if not within_tolerance(target, cur) and cur > target: warnings.append("修正 %d 轮后仍超差:%d 面(目标 %d ±3%%)" % (MAX_RETRY, cur, target)) uv_info = _do_unwrap(obj, unwrap_mode, warnings, overlap_tol) uv_png = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.png" uv_polys_json = os.path.splitext(os.path.abspath(out_fbx))[0] + "_uv.polys.json" uv_preview = None try: polys = _collect_uv_polygons(obj) os.makedirs(os.path.dirname(uv_polys_json), exist_ok=True) with open(uv_polys_json, "w", encoding="utf-8") as fp: json.dump(polys, fp) uv_preview = uv_png # 目标 PNG;实际绘制由 model_decimate(系统 Python + PIL)完成 except Exception as e: warnings.append("UV 几何导出失败(%s),跳过观察图" % e) out_dir = os.path.dirname(os.path.abspath(out_fbx)) os.makedirs(out_dir, exist_ok=True) # 仅导出合并后的对象;整场景导出会把 armature/empty 等非 mesh 带进 _low.fbx bpy.ops.export_scene.fbx(filepath=out_fbx, use_selection=True, path_mode='STRIP', embed_textures=False) print("MT_SUMMARY " + json.dumps( {"src": os.path.basename(src), "fbx": os.path.basename(out_fbx), "tris_before": orig, "tris_after": cur, "target": target, "uv": uv_info, "uv_preview": uv_preview, "uv_polys": uv_polys_json if uv_preview else None, "warnings": warnings}, ensure_ascii=False)) if __name__ == "__main__" and "--" in sys.argv: main()