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

31 KiB
Raw Blame History

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.exefind_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.pyfind_blender/run_blender 同模式,仅泛化脚本名与参数)。

  • Step 1: 写 mt_run.py
"""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
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: 写失败测试

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/ERRORNo module named 'bl_decimate'

  • Step 3: 写 bl_decimate.py 纯逻辑部分
"""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
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 会复用):

# ---------------- 以下仅在 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
"""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
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.fbxtris_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: 写失败测试

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/ERRORNo module named 'bl_bake'

  • Step 3: 写 bl_bake.py 纯逻辑部分
"""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
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 主流程

在纯逻辑之后追加:

# ---------------- 以下仅在 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
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

"""高低模烘焙 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
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:

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:

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「用法」小节后新增一节(保持现有行文风格):

## 减面与高低模烘焙(可选前置工具)

高面数模型先减面、再把细节烘到低模贴图,产物直接作为上面转换器的输入:

​```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
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 编辑器完成。