ModelTranslator: 减面/烘焙工具实现计划
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ffde55bf9a
commit
0b29b5edae
@@ -0,0 +1,833 @@
|
||||
# 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 -- <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 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 -- \
|
||||
<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: 全部 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 编辑器完成。
|
||||
Reference in New Issue
Block a user