Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# 高模减面与贴图烘焙
|
||||
|
||||
复刻自 `ClientTrunk/Tools/ModelTools`(`model_decimate` + `model_bake`)。
|
||||
|
||||
```text
|
||||
高模(FBX / GLB / glTF)
|
||||
-> model_decimate.py:合并 Mesh、清理、三角化、减面、重展 UV
|
||||
-> <名称>_low.fbx
|
||||
-> model_bake.py:Cycles selected-to-active 烘焙
|
||||
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
|
||||
```
|
||||
|
||||
## 环境
|
||||
|
||||
- 安装 Blender(推荐 5.0),并满足其一:
|
||||
- `blender` 在 PATH
|
||||
- 环境变量 `BLENDER_EXE` 或 `BLENDER_PATH`
|
||||
- 运行时 `--blender "D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"`
|
||||
- 本机默认路径已写在 `mt_run.py`:`D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe`
|
||||
- UV 观察图需要 Pillow:`pip install Pillow`
|
||||
- UVPackmaster 4 可选(已装则自动用于 UV 排布)
|
||||
|
||||
## 分步命令
|
||||
|
||||
```powershell
|
||||
python Tools/model_tools/model_decimate.py D:\Assets\house.glb --tris 5000 -o D:\Assets\house_low
|
||||
python Tools/model_tools/model_bake.py D:\Assets\house.glb D:\Assets\house_low\house_low.fbx -o D:\Assets\house_bake --size 2048
|
||||
```
|
||||
|
||||
## 一键流水线(推荐 Agent 使用)
|
||||
|
||||
```powershell
|
||||
python Tools/model_tools/model_lowpoly_bake.py D:\Assets\house.glb -o Tools/output/house --tris 5000 --size 2048
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
```text
|
||||
Tools/output/house/house/
|
||||
house.fbx
|
||||
house_color.png
|
||||
house_normal.png
|
||||
house_ao.png
|
||||
house_metallic.png
|
||||
house_roughness.png
|
||||
```
|
||||
|
||||
生成完成后请在 `Doc/ResDesc_Model.md` 登记资源。
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Blender 内运行:高低模 Cycles 烘焙,低模 UV 上出 normal/ao/color/metallic/roughness。
|
||||
调用:blender -b --factory-startup --python bl_bake.py -- \
|
||||
<high.fbx|high.glb|high.gltf> <low.fbx> <outdir> <size> <ray_distance|auto> <samples>
|
||||
"""
|
||||
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_model_joined(path, warnings):
|
||||
"""导入 FBX/GLB/glTF 并把其中所有 mesh join 成单对象;无 mesh 返回 None。"""
|
||||
import bpy
|
||||
from bl_decimate import import_model_objects, join_meshes
|
||||
new = import_model_objects(path)
|
||||
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
|
||||
obj = join_meshes(meshes)
|
||||
# 应用物体变换(世界坐标不变):cage_extrusion/max_ray_distance 按低模局部空间解释,
|
||||
# 未应用的缩放(如 cm 单位 FBX 的 scale=0.01)会把射线距离缩到近零导致大面积打空(脏色)
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return obj
|
||||
|
||||
|
||||
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):
|
||||
# 该通道无贴图,把常量值直接烘入
|
||||
warnings.append("%s.%s 无贴图,(常量) %.3f 烘入" % (mat.name, input_name, v))
|
||||
emit.inputs['Color'].default_value = (v, v, v, 1.0)
|
||||
else: # Base Color 是 4 分量
|
||||
# 该通道无贴图,把常量色直接烘入
|
||||
warnings.append("%s.%s 无贴图,(常量色 %.3f/%.3f/%.3f) 烘入"
|
||||
% (mat.name, input_name, v[0], v[1], v[2]))
|
||||
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_model, 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_model_joined(high_model, warnings)
|
||||
low = _import_model_joined(low_fbx, warnings)
|
||||
if high is None or low is None:
|
||||
print("MT_SUMMARY " + json.dumps(
|
||||
{"error": "高模或低模中没有 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()
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Blender 内运行:FBX/GLB/glTF 减面到指定三角面数 + UV 重展(锐边 seam 或 Smart UV Project)。
|
||||
调用:blender -b --factory-startup --python bl_decimate.py -- <src> <out.fbx> <target_tris> [seam|smart]
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SUPPORTED_MODEL_EXTENSIONS = (".fbx", ".glb", ".gltf")
|
||||
|
||||
|
||||
def model_format(path):
|
||||
"""Return the Blender importer key for a supported model path."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == ".fbx":
|
||||
return "fbx"
|
||||
if ext in (".glb", ".gltf"):
|
||||
return "gltf"
|
||||
raise ValueError(
|
||||
"unsupported model format %r (supported: %s)"
|
||||
% (ext or "<none>", ", ".join(SUPPORTED_MODEL_EXTENSIONS)))
|
||||
|
||||
|
||||
def import_model_objects(path):
|
||||
"""Import FBX/GLB/glTF and return only objects created by the importer."""
|
||||
import bpy
|
||||
before = set(bpy.data.objects)
|
||||
fmt = model_format(path)
|
||||
if fmt == "fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
return [obj for obj in bpy.data.objects if obj not in before]
|
||||
|
||||
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)
|
||||
imported = import_model_objects(src)
|
||||
meshes = [o for o in imported if o.type == 'MESH']
|
||||
skipped = [o.name for o in imported if o.type != 'MESH']
|
||||
if skipped:
|
||||
warnings.append("忽略非 mesh 对象:%s(本工具只处理静态网格)" % ", ".join(skipped))
|
||||
if not meshes:
|
||||
print("MT_SUMMARY " + json.dumps({"error": "输入模型中没有 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()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""高低模烘焙 CLI:在低模 UV 上烘出 normal/ao/color/metallic/roughness 五张贴图。
|
||||
产物命名对齐 model_translator.py 纯网格约定,可直接作为其输入。
|
||||
用法:python model_bake.py <高模.glb> <低模.fbx> [-o out_bake] [--size 2048]
|
||||
[--ray-distance N] [--samples 64] [--blender exe]"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from bl_bake import output_stem
|
||||
from bl_decimate import model_format
|
||||
from mt_run import HERE, find_blender, run_blender_script
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("high", help="高模 FBX/GLB/glTF(带材质/贴图)")
|
||||
ap.add_argument("low", help="低模 FBX(须有 UV,见 model_decimate.py)")
|
||||
ap.add_argument("-o", "--out", default=os.path.join(HERE, "out_bake"))
|
||||
ap.add_argument("--size", type=int, default=2048, help="烘焙贴图边长")
|
||||
ap.add_argument("--ray-distance", type=float, default=None,
|
||||
help="射线距离(场景单位),默认低模包围盒对角线 2%%")
|
||||
ap.add_argument("--samples", type=int, default=64, help="AO 采样数")
|
||||
ap.add_argument("--blender", default=None)
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
model_format(args.high)
|
||||
if model_format(args.low) != "fbx":
|
||||
ap.error("低模必须是 FBX")
|
||||
except ValueError as exc:
|
||||
ap.error(str(exc))
|
||||
|
||||
blender = find_blender(args.blender)
|
||||
stem = output_stem(os.path.splitext(os.path.basename(args.low))[0])
|
||||
outdir = os.path.join(args.out, stem)
|
||||
ray = "auto" if args.ray_distance is None else str(args.ray_distance)
|
||||
# -t 1:Blender 5.0 的 selected-to-active 烘焙射线求交是多线程的(cast_ray_highpoly
|
||||
# / TBB parallel_for),存在竞争会随机 EXCEPTION_ACCESS_VIOLATION 崩溃;单线程稳定
|
||||
s = run_blender_script(blender, "bl_bake.py",
|
||||
[args.high, args.low, outdir, str(args.size),
|
||||
ray, str(args.samples)],
|
||||
blender_args=("-t", "1"))
|
||||
print("== %s(高 %d 面 / 低 %d 面,射线距离 %.4f)-> %s" %
|
||||
(s["stem"], s["high_tris"], s["low_tris"], s["ray_distance"], outdir))
|
||||
for name, fname in s["outputs"].items():
|
||||
print(" %-10s %s" % (name, fname))
|
||||
for w in s["warnings"]:
|
||||
print(" [警告] %s" % w)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""FBX/GLB/glTF 减面 CLI:减到指定三角面数并重展 UV(seam 展开 + UVPM4 排布),输出 <名>_low.fbx。
|
||||
用法:python model_decimate.py src/well1500.glb --tris 5000 [-o dir]
|
||||
[--unwrap seam|smart] [--max-overlap 0.15] [--blender exe]"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from bl_decimate import model_format
|
||||
from mt_run import find_blender, run_blender_script
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("input", help="高模 FBX/GLB/glTF 文件")
|
||||
ap.add_argument("--tris", type=int, required=True, help="目标三角面数")
|
||||
ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)")
|
||||
ap.add_argument("--unwrap", choices=("seam", "smart"), default="seam",
|
||||
help="UV 展开:seam=锐边接缝整岛展开(默认),smart=Smart UV Project")
|
||||
ap.add_argument("--max-overlap", type=float, default=None,
|
||||
help="UV 重叠容忍上限(0-1,默认内置 0.08);调高可让更高 seam 角度过门、减少碎岛")
|
||||
ap.add_argument("--blender", default=None)
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
model_format(args.input)
|
||||
except ValueError as exc:
|
||||
ap.error(str(exc))
|
||||
if args.tris <= 0:
|
||||
ap.error("--tris 必须为正整数")
|
||||
if args.max_overlap is not None and not (0.0 < args.max_overlap <= 1.0):
|
||||
ap.error("--max-overlap 必须在 (0, 1] 内")
|
||||
|
||||
blender = find_blender(args.blender)
|
||||
name = os.path.splitext(os.path.basename(args.input))[0]
|
||||
outdir = args.out or os.path.dirname(os.path.abspath(args.input))
|
||||
out_fbx = os.path.join(outdir, name + "_low.fbx")
|
||||
|
||||
s = run_blender_script(blender, "bl_decimate.py",
|
||||
[args.input, out_fbx, str(args.tris), args.unwrap,
|
||||
"" if args.max_overlap is None else str(args.max_overlap)])
|
||||
|
||||
print("== %s: %d -> %d 面(目标 %d)-> %s" %
|
||||
(s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx))
|
||||
u = s["uv"]
|
||||
overlap = "%.1f%%" % (u["overlap"] * 100) if u["overlap"] is not None else "n/a"
|
||||
print(" UV: 模式=%s 岛数=%d 利用率=%.1f%% 翻转=%.1f%% 重叠=%s" %
|
||||
(u["mode"], u["islands"], u["fill"] * 100, u["flipped"] * 100, overlap))
|
||||
if s.get("uv_polys"):
|
||||
try:
|
||||
import json as _json
|
||||
from uv_preview import render_uv_png
|
||||
with open(s["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = _json.load(fp)
|
||||
render_uv_png(polys, s["uv_preview"])
|
||||
print(" UV 观察图: %s" % s["uv_preview"])
|
||||
except Exception as e:
|
||||
print(" [警告] UV 观察图渲染失败:%s" % e)
|
||||
finally: # sidecar 是内部临时文件,无论渲染成败都清掉
|
||||
if os.path.exists(s["uv_polys"]):
|
||||
os.remove(s["uv_polys"])
|
||||
for w in s["warnings"]:
|
||||
print(" [警告] %s" % w)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""高模 → 低模减面 + 贴图烘焙(一键流水线)。
|
||||
|
||||
流程对齐 ClientTrunk ModelTools:
|
||||
高模(FBX/GLB/glTF)
|
||||
-> model_decimate:合并 Mesh、清理、三角化、减面、重展 UV -> <名>_low.fbx
|
||||
-> model_bake:Cycles selected-to-active
|
||||
-> 低模 FBX + color / normal / ao / metallic / roughness PNG
|
||||
|
||||
用法:
|
||||
python Tools/model_tools/model_lowpoly_bake.py high.glb -o Tools/output/prop --tris 5000 --size 2048
|
||||
python Tools/model_tools/model_lowpoly_bake.py --json "{\"high\":\"a.glb\",\"out\":\"out/a\",\"tris\":5000}" --result-only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
|
||||
_HERE = Path(__file__).resolve().parent
|
||||
if str(_HERE) not in sys.path:
|
||||
sys.path.insert(0, str(_HERE))
|
||||
|
||||
from bl_bake import output_stem # noqa: E402
|
||||
from bl_decimate import model_format # noqa: E402
|
||||
from mt_run import find_blender, run_blender_script # noqa: E402
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
class CallbackHub:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[Union[str, Path]] = None,
|
||||
quiet: bool = False,
|
||||
) -> None:
|
||||
self.callback_url = callback_url
|
||||
self.callback_file = Path(callback_file) if callback_file else None
|
||||
self.quiet = quiet
|
||||
self.events: List[Dict[str, Any]] = []
|
||||
|
||||
def emit(self, event: str, **payload: Any) -> Dict[str, Any]:
|
||||
record = {"event": event, "ts": _now_iso(), **payload}
|
||||
self.events.append(record)
|
||||
if not self.quiet:
|
||||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||||
if self.callback_file:
|
||||
self.callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.callback_file.write_text(
|
||||
json.dumps(
|
||||
{"latest": record, "history": self.events},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if self.callback_url:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
data = json.dumps(record, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
self.callback_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30.0) as resp:
|
||||
resp.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if not self.quiet:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "callback_error",
|
||||
"ts": _now_iso(),
|
||||
"error": str(exc),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
|
||||
if not raw:
|
||||
return {}
|
||||
if raw == "-":
|
||||
return json.load(sys.stdin)
|
||||
path = Path(raw)
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
*,
|
||||
high: str,
|
||||
out: str,
|
||||
tris: int = 5000,
|
||||
size: int = 2048,
|
||||
samples: int = 64,
|
||||
unwrap: str = "seam",
|
||||
max_overlap: Optional[float] = None,
|
||||
ray_distance: Optional[float] = None,
|
||||
blender: Optional[str] = None,
|
||||
keep_low: bool = True,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_file: Optional[str] = None,
|
||||
quiet: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
callbacks = CallbackHub(
|
||||
callback_url=callback_url, callback_file=callback_file, quiet=quiet
|
||||
)
|
||||
high_path = Path(high).expanduser().resolve()
|
||||
if not high_path.is_file():
|
||||
raise FileNotFoundError(f"高模不存在: {high_path}")
|
||||
try:
|
||||
model_format(str(high_path))
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
if tris <= 0:
|
||||
raise ValueError("--tris 必须为正整数")
|
||||
|
||||
out_dir = Path(out).expanduser().resolve()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = high_path.stem
|
||||
low_dir = out_dir / "_low_work" if not keep_low else out_dir
|
||||
low_dir.mkdir(parents=True, exist_ok=True)
|
||||
low_fbx = low_dir / f"{name}_low.fbx"
|
||||
stem = output_stem(f"{name}_low")
|
||||
bake_dir = out_dir / stem
|
||||
|
||||
blender_exe = find_blender(blender)
|
||||
callbacks.emit(
|
||||
"started",
|
||||
stage="开始高模转低模烘焙",
|
||||
progress=1,
|
||||
high=str(high_path),
|
||||
out=str(out_dir),
|
||||
tris=tris,
|
||||
size=size,
|
||||
blender=blender_exe,
|
||||
)
|
||||
|
||||
callbacks.emit("stage", stage="减面并重展 UV", progress=20)
|
||||
overlap_arg = "" if max_overlap is None else str(max_overlap)
|
||||
dec = run_blender_script(
|
||||
blender_exe,
|
||||
"bl_decimate.py",
|
||||
[str(high_path), str(low_fbx), str(tris), unwrap, overlap_arg],
|
||||
)
|
||||
callbacks.emit(
|
||||
"decimated",
|
||||
stage="减面完成",
|
||||
progress=50,
|
||||
tris_before=dec["tris_before"],
|
||||
tris_after=dec["tris_after"],
|
||||
low_fbx=str(low_fbx),
|
||||
uv=dec.get("uv"),
|
||||
warnings=dec.get("warnings") or [],
|
||||
)
|
||||
|
||||
# Optional UV preview (same as model_decimate.py)
|
||||
if dec.get("uv_polys"):
|
||||
try:
|
||||
from uv_preview import render_uv_png
|
||||
|
||||
with open(dec["uv_polys"], encoding="utf-8") as fp:
|
||||
polys = json.load(fp)
|
||||
render_uv_png(polys, dec["uv_preview"])
|
||||
callbacks.emit(
|
||||
"uv_preview",
|
||||
stage="UV 观察图已生成",
|
||||
progress=55,
|
||||
path=dec["uv_preview"],
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
callbacks.emit(
|
||||
"warning", stage=f"UV 观察图失败: {exc}", progress=55
|
||||
)
|
||||
finally:
|
||||
if os.path.exists(dec["uv_polys"]):
|
||||
os.remove(dec["uv_polys"])
|
||||
|
||||
callbacks.emit("stage", stage="高低模贴图烘焙", progress=60)
|
||||
ray = "auto" if ray_distance is None else str(ray_distance)
|
||||
bake = run_blender_script(
|
||||
blender_exe,
|
||||
"bl_bake.py",
|
||||
[
|
||||
str(high_path),
|
||||
str(low_fbx),
|
||||
str(bake_dir),
|
||||
str(size),
|
||||
ray,
|
||||
str(samples),
|
||||
],
|
||||
blender_args=("-t", "1"),
|
||||
)
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"status": "succeeded",
|
||||
"progress": 100,
|
||||
"high": str(high_path),
|
||||
"low_fbx": str(low_fbx),
|
||||
"bake_dir": str(bake_dir),
|
||||
"fbx": str(bake_dir / bake["fbx"]),
|
||||
"maps": {k: str(bake_dir / v) for k, v in bake["outputs"].items()},
|
||||
"tris_before": dec["tris_before"],
|
||||
"tris_after": bake["low_tris"],
|
||||
"high_tris": bake["high_tris"],
|
||||
"size": size,
|
||||
"ray_distance": bake["ray_distance"],
|
||||
"warnings": list(dec.get("warnings") or []) + list(bake.get("warnings") or []),
|
||||
"hint": "生成完成后请在 Doc/ResDesc_Model.md 登记该资源",
|
||||
}
|
||||
callbacks.emit("succeeded", **result)
|
||||
return result
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="model_lowpoly_bake",
|
||||
description="高模减面 + Cycles 贴图烘焙(一键)",
|
||||
)
|
||||
p.add_argument("high", nargs="?", help="高模 FBX/GLB/glTF")
|
||||
p.add_argument("-o", "--out", help="输出目录")
|
||||
p.add_argument("--tris", type=int, default=5000, help="目标三角面数,默认 5000")
|
||||
p.add_argument("--size", type=int, default=2048, help="烘焙贴图边长,默认 2048")
|
||||
p.add_argument("--samples", type=int, default=64, help="AO 采样数,默认 64")
|
||||
p.add_argument(
|
||||
"--unwrap",
|
||||
choices=("seam", "smart"),
|
||||
default="seam",
|
||||
help="UV 展开模式,默认 seam",
|
||||
)
|
||||
p.add_argument("--max-overlap", type=float, default=None, help="UV 重叠容忍 (0,1]")
|
||||
p.add_argument("--ray-distance", type=float, default=None, help="烘焙射线距离")
|
||||
p.add_argument("--blender", default=None, help="blender.exe 路径")
|
||||
p.add_argument(
|
||||
"--callback-url", default=None, help="流程回调 URL:每个事件 POST JSON"
|
||||
)
|
||||
p.add_argument(
|
||||
"--callback-file",
|
||||
default=None,
|
||||
help="流程回调状态文件(latest/history)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
dest="json_input",
|
||||
default=None,
|
||||
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
|
||||
)
|
||||
p.add_argument("--result-only", action="store_true")
|
||||
p.add_argument("--quiet", action="store_true")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
payload = _load_json_arg(args.json_input)
|
||||
quiet = bool(args.quiet or args.result_only or payload.get("quiet"))
|
||||
|
||||
high = payload.get("high") or args.high
|
||||
out = payload.get("out") or payload.get("output") or args.out
|
||||
if not high or not out:
|
||||
parser.error("必须提供高模路径与 -o/--out(或 JSON.high / JSON.out)")
|
||||
|
||||
try:
|
||||
result = run_pipeline(
|
||||
high=high,
|
||||
out=out,
|
||||
tris=int(payload.get("tris") or args.tris),
|
||||
size=int(payload.get("size") or args.size),
|
||||
samples=int(payload.get("samples") or args.samples),
|
||||
unwrap=payload.get("unwrap") or args.unwrap,
|
||||
max_overlap=(
|
||||
float(payload["max_overlap"])
|
||||
if payload.get("max_overlap") is not None
|
||||
else args.max_overlap
|
||||
),
|
||||
ray_distance=(
|
||||
float(payload["ray_distance"])
|
||||
if payload.get("ray_distance") is not None
|
||||
else args.ray_distance
|
||||
),
|
||||
blender=payload.get("blender") or args.blender,
|
||||
callback_url=payload.get("callback_url") or args.callback_url,
|
||||
callback_file=payload.get("callback_file") or args.callback_file,
|
||||
quiet=quiet,
|
||||
)
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
err = {"ok": False, "error": str(exc)}
|
||||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||||
return 1
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""find_blender / run_blender_script:model_decimate 与 model_bake 共享的
|
||||
headless Blender 运行器。"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_BLENDER = r"D:\tools\Blender\blender-5.0.0-windows-x64\blender.exe"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def find_blender(cli_arg):
|
||||
"""Resolve blender.exe from --blender / BLENDER_EXE / BLENDER_PATH / default / PATH."""
|
||||
candidates = (
|
||||
cli_arg,
|
||||
os.environ.get("BLENDER_EXE"),
|
||||
os.environ.get("BLENDER_PATH"),
|
||||
DEFAULT_BLENDER,
|
||||
shutil.which("blender"),
|
||||
)
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
path = cand
|
||||
# Allow BLENDER_PATH to point at a directory containing blender.exe
|
||||
if os.path.isdir(path):
|
||||
nested = os.path.join(path, "blender.exe")
|
||||
if os.path.isfile(nested):
|
||||
return nested
|
||||
continue
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
sys.exit(
|
||||
"找不到 blender.exe,请用 --blender 指定,或设置环境变量 BLENDER_EXE / BLENDER_PATH"
|
||||
)
|
||||
|
||||
|
||||
def run_blender_script(blender, script, script_args, blender_args=()):
|
||||
"""跑 HERE 下的 bl_*.py,返回 MT_SUMMARY JSON;失败或 summary 带 error 则退出。"""
|
||||
cmd = [
|
||||
blender,
|
||||
"-b",
|
||||
*blender_args,
|
||||
"--factory-startup",
|
||||
"--python",
|
||||
os.path.join(HERE, script),
|
||||
"--",
|
||||
] + list(script_args)
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
summary = None
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
if line.startswith("MT_SUMMARY "):
|
||||
summary = json.loads(line[len("MT_SUMMARY ") :])
|
||||
if proc.returncode != 0 or summary is None or "error" in summary:
|
||||
sys.stderr.write(
|
||||
(proc.stdout or "")[-2000:] + "\n" + (proc.stderr or "")[-2000:] + "\n"
|
||||
)
|
||||
err = summary["error"] if summary and "error" in summary else script
|
||||
sys.exit("Blender 执行失败:%s" % err)
|
||||
return summary
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from bl_decimate import model_format
|
||||
|
||||
|
||||
class ModelFormatTests(unittest.TestCase):
|
||||
def test_fbx(self):
|
||||
self.assertEqual(model_format("asset.FBX"), "fbx")
|
||||
|
||||
def test_glb(self):
|
||||
self.assertEqual(model_format("asset.GLB"), "gltf")
|
||||
|
||||
def test_gltf(self):
|
||||
self.assertEqual(model_format("asset.gltf"), "gltf")
|
||||
|
||||
def test_unsupported(self):
|
||||
with self.assertRaisesRegex(ValueError, "unsupported model format"):
|
||||
model_format("asset.obj")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""UV 线框观察图渲染:把 UV 多边形(归一化 0-1 坐标)画成 PNG 供人工查阅。
|
||||
在系统 Python(有 Pillow)中运行——Blender 自带 Python 无 PIL,故 UV 几何由
|
||||
bl_decimate 导出为 JSON,本模块负责绘制。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
def _to_px(u, v, size, pad):
|
||||
"""UV(0-1, 原点左下) -> 像素(原点左上),含边距。"""
|
||||
span = size - 2 * pad
|
||||
x = pad + u * span
|
||||
y = pad + (1.0 - v) * span # V 轴翻转:图像 y 向下
|
||||
return x, y
|
||||
|
||||
|
||||
def render_uv_png(polygons, out_png, size=1024, pad=8,
|
||||
line=(30, 30, 30, 255), fill=(80, 140, 220, 64)):
|
||||
"""polygons: [[[u,v], ...], ...] 每个多边形一组 UV 顶点。
|
||||
白底 + 半透明填充 + 深色线框;写 PNG 到 out_png,返回 out_png。"""
|
||||
base = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay, "RGBA")
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, fill=fill)
|
||||
for poly in polygons:
|
||||
pts = [_to_px(u, v, size, pad) for (u, v) in poly]
|
||||
if len(pts) >= 2:
|
||||
draw.line(pts + pts[:1], fill=line, width=1)
|
||||
Image.alpha_composite(base, overlay).convert("RGB").save(out_png, "PNG")
|
||||
return out_png
|
||||
Reference in New Issue
Block a user