# ModelTranslator 减面与烘焙工具实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在 `Tools/ModelTranslator` 新增两个 headless Blender 工具——FBX 减面到指定三角面数(含 Smart UV 重展),以及高低模烘焙分离出 normal/ao/color/metallic/roughness 五张贴图。 **Architecture:** 沿用现有"系统 Python CLI 包装器 + `blender -b --factory-startup --python bl_*.py` + stdout `MT_SUMMARY ` JSON 行协议"模式。纯逻辑函数放模块顶部(不 import bpy,可单测),Blender 专属代码全部延迟导入 bpy。烘焙用 Cycles selected-to-active:NORMAL/AO pass 直接烘,color/metallic/roughness 用 EMIT trick(把 Principled 对应输入改接 Emission)。 **Tech Stack:** Python 3 标准库、Blender 5.0(bpy)、unittest。 **设计文档:** `docs/superpowers/specs/2026-07-16-model-decimate-bake-design.md` **重要提醒:** - 仓库路径含 `#`(`D:\UD\AI\AIC#Project`),**bash 里所有路径必须加引号** - Blender 位于 `D:\tools\blender-5.0.0-windows-x64\blender.exe`(`find_blender` 的默认值) - 测试素材:`Tools/ModelTranslator/src/well1500.fbx`(高模),目标 5000 面 - `Tools/ModelTranslator/src/` 与产物目录均**不入 git**(现状即未跟踪),只提交代码/文档 ## 文件结构 ``` Tools/ModelTranslator/ mt_run.py # 新增:find_blender + run_blender_script(两个新 CLI 共享) model_decimate.py # 新增:工具1 CLI bl_decimate.py # 新增:工具1 Blender 内脚本(含可单测纯逻辑) model_bake.py # 新增:工具2 CLI bl_bake.py # 新增:工具2 Blender 内脚本(含可单测纯逻辑) tests/ test_bl_decimate.py # 新增 test_bl_bake.py # 新增 README.md # 修改:补两个新工具的用法与管线说明 ``` (`model_translator.py` 不动,避免破坏已验证的工具3。) --- ### Task 1: mt_run.py 共享 Blender 运行器 **Files:** - Create: `Tools/ModelTranslator/mt_run.py` 纯 subprocess 薄封装,无业务逻辑,不写单测(与现有 `model_translator.py` 的 `find_blender`/`run_blender` 同模式,仅泛化脚本名与参数)。 - [ ] **Step 1: 写 mt_run.py** ```python """find_blender / run_blender_script:model_decimate 与 model_bake 共享的 headless Blender 运行器(与 model_translator.py 同模式,脚本名与参数泛化)。""" import json import os import shutil import subprocess import sys DEFAULT_BLENDER = r"D:\tools\blender-5.0.0-windows-x64\blender.exe" HERE = os.path.dirname(os.path.abspath(__file__)) def find_blender(cli_arg): for cand in (cli_arg, os.environ.get("BLENDER_EXE"), DEFAULT_BLENDER, shutil.which("blender")): if cand and os.path.isfile(cand): return cand sys.exit("找不到 blender.exe,请用 --blender 指定或设置环境变量 BLENDER_EXE") def run_blender_script(blender, script, script_args): """跑 HERE 下的 bl_*.py,返回 MT_SUMMARY JSON;失败或 summary 带 error 则退出。""" cmd = [blender, "-b", "--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 ``` - [ ] **Step 2: 语法检查** Run: `python -m py_compile "D:/UD/AI/AIC#Project/Tools/ModelTranslator/mt_run.py"` Expected: 无输出(编译通过) - [ ] **Step 3: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/mt_run.py && git commit -m "ModelTranslator: 共享 Blender 运行器 mt_run.py" ``` --- ### Task 2: bl_decimate.py 纯逻辑(TDD) **Files:** - Create: `Tools/ModelTranslator/tests/test_bl_decimate.py` - Create: `Tools/ModelTranslator/bl_decimate.py`(本任务只写纯逻辑部分) - [ ] **Step 1: 写失败测试** ```python import os import sys import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import bl_decimate as bd class TestDecimateRatio(unittest.TestCase): def test_normal_reduction(self): self.assertAlmostEqual(bd.decimate_ratio(5000, 1500000), 5000 / 1500000.0) def test_current_below_target_returns_one(self): self.assertEqual(bd.decimate_ratio(5000, 3000), 1.0) def test_current_equal_target_returns_one(self): self.assertEqual(bd.decimate_ratio(5000, 5000), 1.0) def test_zero_current_returns_one(self): self.assertEqual(bd.decimate_ratio(5000, 0), 1.0) class TestWithinTolerance(unittest.TestCase): def test_exact_hit(self): self.assertTrue(bd.within_tolerance(5000, 5000)) def test_within_3_percent(self): self.assertTrue(bd.within_tolerance(5000, 5150)) # +3% self.assertTrue(bd.within_tolerance(5000, 4850)) # -3% def test_outside_3_percent(self): self.assertFalse(bd.within_tolerance(5000, 5200)) self.assertFalse(bd.within_tolerance(5000, 4700)) def test_zero_target_is_false(self): self.assertFalse(bd.within_tolerance(0, 0)) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: 运行确认失败** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v` Expected: FAIL/ERROR(`No module named 'bl_decimate'`) - [ ] **Step 3: 写 bl_decimate.py 纯逻辑部分** ```python """Blender 内运行:FBX 减面到指定三角面数 + Smart UV 重展。 调用:blender -b --factory-startup --python bl_decimate.py -- """ 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 ``` - [ ] **Step 4: 运行确认通过** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_decimate -v` Expected: 全部 PASS(8 个测试) - [ ] **Step 5: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/tests/test_bl_decimate.py && git commit -m "ModelTranslator: 减面 ratio/容差纯逻辑(TDD)" ``` --- ### Task 3: bl_decimate.py Blender 主流程 + model_decimate.py CLI **Files:** - Modify: `Tools/ModelTranslator/bl_decimate.py`(追加 Blender 专属部分) - Create: `Tools/ModelTranslator/model_decimate.py` Blender ops 无法脱离 Blender 单测,正确性由 Task 4 冒烟测试验证。 - [ ] **Step 1: bl_decimate.py 追加 Blender 主流程** 在纯逻辑之后追加(`join_meshes` 为公开函数,Task 6 的 bl_bake 会复用): ```python # ---------------- 以下仅在 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 _smart_unwrap(obj): """删旧 UV 层后 Smart UV Project 重展。island_margin 0.002 ≈ 2048 图 4px。""" import bpy import math mesh = obj.data while mesh.uv_layers: mesh.uv_layers.remove(mesh.uv_layers[0]) 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=0.002) bpy.ops.object.mode_set(mode='OBJECT') def main(): import bpy import json argv = sys.argv[sys.argv.index("--") + 1:] src, out_fbx, target = argv[0], argv[1], int(argv[2]) 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 _smart_unwrap(obj) out_dir = os.path.dirname(os.path.abspath(out_fbx)) os.makedirs(out_dir, exist_ok=True) bpy.ops.export_scene.fbx(filepath=out_fbx, 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, "warnings": warnings}, ensure_ascii=False)) if __name__ == "__main__" and "--" in sys.argv: main() ``` - [ ] **Step 2: 写 model_decimate.py** ```python """FBX 减面 CLI:减到指定三角面数并 Smart UV 重展,输出 <名>_low.fbx。 用法:python model_decimate.py src/well1500.fbx --tris 5000 [-o dir] [--blender exe]""" import argparse import os from mt_run import find_blender, run_blender_script def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("input", help="FBX 文件") ap.add_argument("--tris", type=int, required=True, help="目标三角面数") ap.add_argument("-o", "--out", default=None, help="输出目录(默认源文件同目录)") ap.add_argument("--blender", default=None) args = ap.parse_args() 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)]) print("== %s: %d -> %d 面(目标 %d)-> %s" % (s["src"], s["tris_before"], s["tris_after"], s["target"], out_fbx)) for w in s["warnings"]: print(" [警告] %s" % w) if __name__ == "__main__": main() ``` - [ ] **Step 3: 语法检查 + 既有测试不回归** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile bl_decimate.py model_decimate.py && python -m unittest -v` Expected: 编译通过,全部单测 PASS - [ ] **Step 4: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_decimate.py Tools/ModelTranslator/model_decimate.py && git commit -m "ModelTranslator: FBX 减面工具 model_decimate(Decimate collapse + Smart UV 重展)" ``` --- ### Task 4: 工具1 冒烟测试(well1500 → 5000 面) **Files:** 无新文件;产出 `Tools/ModelTranslator/src/well1500_low.fbx`(不入 git) - [ ] **Step 1: 跑减面** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_decimate.py src/well1500.fbx --tris 5000` Expected: 输出形如 `== well1500.fbx: NNNNNN -> ~5000 面(目标 5000)-> ...well1500_low.fbx`,`tris_after` 在 4850~5150 之间(±3%)或 ≤5000 - [ ] **Step 2: 确认产物存在** Run: `ls -la "D:/UD/AI/AIC#Project/Tools/ModelTranslator/src/well1500_low.fbx"` Expected: 文件存在且非 0 字节 - [ ] **Step 3: 若面数超差或报错** 按 superpowers:systematic-debugging 排查(常见点:Blender 5.0 `smart_project` 参数名、`modifier_apply` 上下文),修复后重跑 Step 1。**不达标不得进入 Task 5。** --- ### Task 5: bl_bake.py 纯逻辑(TDD) **Files:** - Create: `Tools/ModelTranslator/tests/test_bl_bake.py` - Create: `Tools/ModelTranslator/bl_bake.py`(本任务只写纯逻辑部分) - [ ] **Step 1: 写失败测试** ```python import math import os import sys import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import bl_bake as bb class TestEstimateRayDistance(unittest.TestCase): def test_two_percent_of_bbox_diagonal(self): # 3-4-12 直角箱对角线 = 13 self.assertAlmostEqual(bb.estimate_ray_distance((3.0, 4.0, 12.0)), 0.26) def test_custom_pct(self): self.assertAlmostEqual( bb.estimate_ray_distance((1.0, 0.0, 0.0), pct=0.5), 0.5) class TestOutputStem(unittest.TestCase): def test_strips_low_suffix(self): self.assertEqual(bb.output_stem("well1500_low"), "well1500") def test_keeps_name_without_suffix(self): self.assertEqual(bb.output_stem("well1500"), "well1500") def test_only_strips_trailing_suffix(self): self.assertEqual(bb.output_stem("low_poly_low"), "low_poly") class TestBboxMismatch(unittest.TestCase): def test_identical_ok(self): self.assertFalse(bb.bbox_mismatch((1.0, 2.0, 3.0), (1.0, 2.0, 3.0))) def test_within_10_percent_ok(self): self.assertFalse(bb.bbox_mismatch((1.0, 2.0, 3.0), (1.05, 1.9, 3.2))) def test_one_axis_exceeds(self): self.assertTrue(bb.bbox_mismatch((1.0, 2.0, 3.0), (1.0, 2.0, 3.5))) def test_zero_axis_ignored(self): # 平面模型某轴为 0,不应误报 self.assertFalse(bb.bbox_mismatch((1.0, 0.0, 3.0), (1.0, 0.0, 3.0))) if __name__ == "__main__": unittest.main() ``` - [ ] **Step 2: 运行确认失败** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_bake -v` Expected: FAIL/ERROR(`No module named 'bl_bake'`) - [ ] **Step 3: 写 bl_bake.py 纯逻辑部分** ```python """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 ``` - [ ] **Step 4: 运行确认通过** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_bake -v` Expected: 全部 PASS(9 个测试) - [ ] **Step 5: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/tests/test_bl_bake.py && git commit -m "ModelTranslator: 烘焙射线距离/命名/包围盒校验纯逻辑(TDD)" ``` --- ### Task 6: bl_bake.py Blender 主流程 **Files:** - Modify: `Tools/ModelTranslator/bl_bake.py`(追加 Blender 专属部分) 关键点:① NORMAL/AO 先烘(依赖高模原始材质),EMIT 三张后烘(会改写节点图,headless 一次性会话无需还原);② 烘焙目标图的颜色空间决定 PNG 编码——Cycles 向字节图写入时按图颜色空间转换,color 图建为 sRGB 得到 sRGB 编码 PNG,其余 Non-Color 保持线性原值(对应 well10k 双重 sRGB 教训,Unity 端 sRGB 开关由工具3 `.meta` 控制)。 - [ ] **Step 1: 追加 Blender 主流程** 在纯逻辑之后追加: ```python # ---------------- 以下仅在 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() ``` - [ ] **Step 2: 语法检查 + 纯逻辑测试不回归** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile bl_bake.py && python -m unittest tests.test_bl_bake -v` Expected: 编译通过,9 个测试 PASS - [ ] **Step 3: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/bl_bake.py && git commit -m "ModelTranslator: 高低模烘焙 Blender 主流程(NORMAL/AO + EMIT trick)" ``` --- ### Task 7: model_bake.py CLI **Files:** - Create: `Tools/ModelTranslator/model_bake.py` - [ ] **Step 1: 写 model_bake.py** ```python """高低模烘焙 CLI:在低模 UV 上烘出 normal/ao/color/metallic/roughness 五张贴图。 产物命名对齐 model_translator.py 纯网格约定,可直接作为其输入。 用法:python model_bake.py <高模.fbx> <低模.fbx> [-o out_bake] [--size 2048] [--ray-distance N] [--samples 64] [--blender exe]""" import argparse import os from bl_bake import output_stem from mt_run import HERE, find_blender, run_blender_script def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("high", help="高模 FBX(带材质/贴图)") 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() 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) s = run_blender_script(blender, "bl_bake.py", [args.high, args.low, outdir, str(args.size), ray, str(args.samples)]) 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() ``` - [ ] **Step 2: 语法检查 + 全部单测** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m py_compile model_bake.py && python -m unittest -v` Expected: 编译通过,全部单测 PASS - [ ] **Step 3: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/model_bake.py && git commit -m "ModelTranslator: 烘焙工具 CLI model_bake.py" ``` --- ### Task 8: 工具2 冒烟测试(well1500 高模 + well1500_low 低模) **Files:** 无新文件;产出 `Tools/ModelTranslator/out_bake/well1500/`(不入 git) - [ ] **Step 1: 跑烘焙** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_bake.py src/well1500.fbx src/well1500_low.fbx` Expected: 打印 5 行输出贴图名,无 error 退出(高面数模型 CPU 烘焙可能要几分钟,Bash timeout 给到 600000) - [ ] **Step 2: 校验产物:fbx + 5 张 2048 PNG** Run: ```bash cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator/out_bake/well1500" && ls && python -c " import struct, glob for p in sorted(glob.glob('*.png')): w, h = struct.unpack('>II', open(p, 'rb').read(24)[16:24]) print(p, w, h)" ``` Expected: `well1500.fbx` 存在;`well1500_color/normal/metallic/roughness/ao.png` 五张均为 `2048 2048` - [ ] **Step 3: 抽查烘焙内容非空** Run: ```bash cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && "D:/tools/blender-5.0.0-windows-x64/blender.exe" -b --factory-startup --python-expr " import bpy for n in ('color', 'normal'): img = bpy.data.images.load(r'D:\UD\AI\AIC#Project\Tools\ModelTranslator\out_bake\well1500\well1500_%s.png' % n) px = list(img.pixels[:4000]) uniq = len(set(round(v, 2) for v in px)) print('CHECK', n, 'unique=', uniq) " ``` Expected: 两张图 `unique=` 都明显 >1(纯黑/纯灰说明烘焙落空——射线距离或 selected-to-active 出了问题,按 systematic-debugging 排查后重跑) --- ### Task 9: 全管线验证 + README 更新 **Files:** - Modify: `Tools/ModelTranslator/README.md` - [ ] **Step 1: 工具3 吃工具2 产物** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python model_translator.py out_bake/well1500/well1500.fbx` Expected: 输出 `out/well1500/`,日志显示 color/normal/metallic/roughness/ao 五个来源均识别到(无"(常量)"),生成 base/mix PNG + .mat + .meta - [ ] **Step 2: README 补两个新工具** 在 README.md「用法」小节后新增一节(保持现有行文风格): ```markdown ## 减面与高低模烘焙(可选前置工具) 高面数模型先减面、再把细节烘到低模贴图,产物直接作为上面转换器的输入: ​```bash cd Tools/ModelTranslator python model_decimate.py src/well1500.fbx --tris 5000 # -> src/well1500_low.fbx python model_bake.py src/well1500.fbx src/well1500_low.fbx # -> out_bake/well1500/ python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/(Unity 资源) ​``` - **model_decimate.py**:多 mesh 自动 join;三角化后 Decimate(collapse) 减到 `--tris`(±3%,最多 2 轮修正);旧 UV 全删,Smart UV Project 重展;输出 `<名>_low.fbx`(`-o` 改目录) - **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);低模无 UV 会报错,高低模包围盒差超 10% 打警告 - 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定 ``` (注意:示例中的 ``` 围栏按 README 实际格式写,上面的 ​``` 仅为本计划文档转义。) - [ ] **Step 3: 全部单测最后过一遍** Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest -v` Expected: 全部 PASS - [ ] **Step 4: Commit** ```bash cd "D:/UD/AI/AIC#Project" && git add Tools/ModelTranslator/README.md && git commit -m "ModelTranslator: README 补减面/烘焙工具用法(well1500 全管线冒烟通过)" ``` - [ ] **Step 5: Unity 手动验证(用户操作)** 把 `out/well1500/` 拷入 `Client/Assets/`,拖进场景确认外观正常(法线/颜色/粗糙度无明显异常、无双重 gamma 变亮)。此步由用户在 Unity 编辑器完成。