267 lines
9.3 KiB
Python
267 lines
9.3 KiB
Python
"""Blender 内运行:FBX 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
|
||
调用:blender -b --factory-startup --python bl_decimate.py -- <src.fbx> <out.fbx> <target_tris> [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_BAD_TOL = 0.02 # 质量门:UV 翻转/重叠面占比超此值则回退 smart_project
|
||
|
||
|
||
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)
|
||
|
||
|
||
SEAM_ANGLE_DEG = 66.0 # 锐边阈值:两面夹角超此值标 seam
|
||
PACK_MARGIN = 0.002 # 岛间距 ≈ 2048 图 4px
|
||
|
||
|
||
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 -> MINIMUM_STRETCH/ANGLE_BASED 展开 -> pack。碎岛远少于投影式。"""
|
||
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')
|
||
# 锐边标 seam:接缝只落在硬边处,平滑区域保持整岛
|
||
bpy.ops.mesh.select_mode(type='EDGE')
|
||
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.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)
|
||
|
||
|
||
def _do_unwrap(obj, mode, warnings):
|
||
"""按模式展开;seam 质量不达标自动回退 smart。返回 uv 指标 dict(含 mode)。"""
|
||
if mode == "seam":
|
||
_unwrap_seam(obj, warnings)
|
||
m = _collect_uv_metrics(obj)
|
||
overlap = m["overlap"] or 0.0
|
||
if m["flipped"] <= UV_BAD_TOL and overlap <= UV_BAD_TOL:
|
||
m["mode"] = "seam"
|
||
return m
|
||
warnings.append("seam 展开质量不达标(翻转 %.1f%% 重叠 %s),回退 smart_project"
|
||
% (m["flipped"] * 100,
|
||
"%.1f%%" % (m["overlap"] * 100) if m["overlap"] is not None else "未知"))
|
||
mode = "smart_fallback"
|
||
_unwrap_smart(obj)
|
||
m = _collect_uv_metrics(obj)
|
||
m["mode"] = mode if mode == "smart_fallback" else "smart"
|
||
return m
|
||
|
||
|
||
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"
|
||
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)
|
||
|
||
_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)
|
||
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, "warnings": warnings}, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__" and "--" in sys.argv:
|
||
main()
|