Files
AIC-Project/docs/superpowers/plans/2026-07-16-model-decimate-bake.md
2026-07-16 10:30:17 +08:00

834 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-activeNORMAL/AO pass 直接烘,color/metallic/roughness 用 EMIT trick(把 Principled 对应输入改接 Emission)。
**Tech Stack:** Python 3 标准库、Blender 5.0bpy)、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_scriptmodel_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 -- <src.fbx> <out.fbx> <target_tris>
"""
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 ratiocur <= 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: 全部 PASS8 个测试)
- [ ] **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_decimateDecimate 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 -- \
<high.fbx> <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
```
- [ ] **Step 4: 运行确认通过**
Run: `cd "D:/UD/AI/AIC#Project/Tools/ModelTranslator" && python -m unittest tests.test_bl_bake -v`
Expected: 全部 PASS9 个测试)
- [ ] **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 编辑器完成。