Files
AIC-Project/docs/superpowers/plans/2026-07-23-adaptive-ray-bake.md
T
2026-07-23 17:25:44 +08:00

17 KiB
Raw Blame History

model_bake 自适应射线距离 Implementation Plan

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:model_bakeauto 射线从固定"低模包围盒对角线 2%"改为按实测低↔高分离距离自适应,消除薄部件(如裙子)烘焙射线穿透打到背/内面造成的深色脏斑。

Architecture: 纯函数 percentile / adaptive_ray_distancebl_bake.py 可 TDDBlender 侧加 _measure_separation(高模 BVH 测低模面心最近距)并在 main 的 auto 分支替换固定射线,失败回退固定 2%。显式 --ray-distance 仍覆盖。

Tech Stack: Python 标准库、Blender 5.0 bpy/mathutilsunittest、现有 headless Blender runner。

设计依据:docs/superpowers/specs/2026-07-23-adaptive-ray-bake-design.md

Global Constraints

  • 不加外部依赖。
  • 显式 --ray-distance 优先;自适应失败回退固定 2%,绝不中断烘焙。
  • 不改烘焙 pass 结构/输出命名/贴图协议;MT_SUMMARY 只增 ray 字段。
  • 纯逻辑 TDDBlender 侧由真机 smoke 覆盖。
  • 只 stage 本计划源码/文档;不碰用户脏工作区与 out_* 产物。

File Map

  • Modify Tools/ModelTranslator/bl_bake.py:加 percentileadaptive_ray_distance、常量;加 _measure_separationmain auto 分支自适应 + MT_SUMMARY.ray
  • Modify Tools/ModelTranslator/tests/test_bl_bake.pypercentileadaptive_ray_distance 单测。
  • Modify Tools/ModelTranslator/model_bake.py:打印射线来源行。
  • Modify Tools/ModelTranslator/README.md:记录自适应射线。

Task 1: 纯函数(percentile + adaptive_ray_distance

Files:

  • Modify: Tools/ModelTranslator/bl_bake.py

  • Modify: Tools/ModelTranslator/tests/test_bl_bake.py

  • Step 1: 写失败单测

tests/test_bl_bake.pyif __name__ == "__main__": 之前插入:

class TestPercentile(unittest.TestCase):
    def test_interpolates(self):
        self.assertAlmostEqual(bb.percentile([0.0, 10.0], 0.25), 2.5)

    def test_rejects_empty(self):
        with self.assertRaises(ValueError):
            bb.percentile([], 0.99)

    def test_rejects_bad_q(self):
        with self.assertRaises(ValueError):
            bb.percentile([1.0], 1.1)


class TestAdaptiveRayDistance(unittest.TestCase):
    def test_p99_with_safety(self):
        r = bb.adaptive_ray_distance([0.002] * 20, (1.0, 0.0, 0.0))
        self.assertAlmostEqual(r["distance_p99"], 0.002)
        self.assertAlmostEqual(r["value"], 0.003)   # 0.002*1.5,未夹
        self.assertFalse(r["capped"])

    def test_lower_floor(self):
        r = bb.adaptive_ray_distance([0.0], (1.0, 0.0, 0.0))
        self.assertAlmostEqual(r["value"], 0.0005)  # 下限 0.05%
        self.assertFalse(r["capped"])

    def test_upper_cap(self):
        r = bb.adaptive_ray_distance([0.02], (1.0, 0.0, 0.0))
        self.assertAlmostEqual(r["value"], 0.01)    # 夹到 1% 上限
        self.assertTrue(r["capped"])

    def test_zero_bbox_raises(self):
        with self.assertRaises(ValueError):
            bb.adaptive_ray_distance([0.01], (0.0, 0.0, 0.0))
  • Step 2: 跑测试确认 RED

Run(在 Tools/ModelTranslator):

python -m unittest tests.test_bl_bake.TestPercentile tests.test_bl_bake.TestAdaptiveRayDistance -v

Expected: AttributeError: module 'bl_bake' has no attribute 'percentile'(及 adaptive_ray_distance)。

  • Step 3: 实现纯函数

bl_bake.pyDEFAULT_RAY_PCT = 0.02 那行之后新增常量:

RAY_SAFETY = 1.5        # 自适应射线安全系数(分离 P99 × 此值)
RAY_LOWER_PCT = 0.0005  # 射线下限 = 包围盒对角线 * 0.05%
RAY_UPPER_PCT = 0.01    # 射线上限 = 包围盒对角线 * 1%

estimate_ray_distance 函数之后新增两个纯函数:

def percentile(values, q):
    """插值分位数。空列表或 q∉[0,1] 抛 ValueError。"""
    if not values:
        raise ValueError("percentile 需要至少一个值")
    if not 0.0 <= q <= 1.0:
        raise ValueError("分位数 q 必须在 [0,1]")
    ordered = sorted(float(v) for v in values)
    pos = (len(ordered) - 1) * q
    lo = int(math.floor(pos))
    hi = int(math.ceil(pos))
    if lo == hi:
        return ordered[lo]
    return ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo)


def adaptive_ray_distance(distances, bbox_dims):
    """按低↔高分离距离定射线:P99×RAY_SAFETY,夹在对角线 [0.05%,1%]。
    返回 {distance_p99, value, capped};包围盒退化抛 ValueError。"""
    diagonal = math.sqrt(sum(float(d) * float(d) for d in bbox_dims))
    if diagonal <= 0.0:
        raise ValueError("低模包围盒对角线必须为正")
    p99 = percentile(distances, 0.99)
    lower = diagonal * RAY_LOWER_PCT
    upper = diagonal * RAY_UPPER_PCT
    raw = max(p99 * RAY_SAFETY, lower)
    return {"distance_p99": p99, "value": min(raw, upper), "capped": raw > upper}
  • Step 4: 跑测试确认 GREEN + 回归

Run

python -m unittest tests.test_bl_bake -v
python -m unittest discover -s tests

Expected: 新测试全过;现有 TestEstimateRayDistance 等仍过;全量零失败。

  • Step 5: 提交
git add -- Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/tests/test_bl_bake.py
git commit -m "ModelTranslator: adaptive ray distance pure functions"

提交信息末尾附一行:Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>


Task 2: Blender 侧分离测量 + main 集成 + 打印

Files:

  • Modify: Tools/ModelTranslator/bl_bake.py
  • Modify: Tools/ModelTranslator/model_bake.py

_measure_separation/main 依赖 bpy,无法纯单测;本任务 py_compile + 回归把关,真机 smoke 在 Task 3。

  • Step 1: 加 _measure_separation

bl_bake.py_import_fbx_joined 函数之后(# ---------------- 以下仅在 Blender 内执行 ---------------- 区内)插入:

def _measure_separation(low, high, limit=10000):
    """低模面心到高模表面最近距离数组(供自适应射线)。超 limit 面则均匀采样。
    高模 BVH 无命中的面丢弃;返回距离列表(可能为空)。"""
    from mathutils.bvhtree import BVHTree
    hv = [high.matrix_world @ v.co for v in high.data.vertices]
    hp = [tuple(p.vertices) for p in high.data.polygons]
    bvh = BVHTree.FromPolygons(hv, hp, all_triangles=False)
    polys = low.data.polygons
    n = len(polys)
    if n == 0:
        return []
    if n <= limit:
        idxs = range(n)
    else:
        idxs = [round(i * (n - 1) / float(limit - 1)) for i in range(limit)]
    mw = low.matrix_world
    dists = []
    for i in idxs:
        hit = bvh.find_nearest(mw @ polys[i].center)
        if hit[0] is not None and hit[3] is not None:
            dists.append(hit[3])
    return dists
  • Step 2: main auto 分支改自适应

bl_bake.py main() 中,找到这一行:

    ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)

整体替换为:

    if ray_arg != "auto":
        ray = float(ray_arg)
        ray_info = {"source": "explicit", "distance_p99": None,
                    "value": ray, "capped": False}
    else:
        dists = _measure_separation(low, high)
        try:
            if not dists:
                raise ValueError("低模面心到高模无有效最近距离")
            ray_info = adaptive_ray_distance(dists, low_dims)
            ray_info["source"] = "adaptive"
            ray = ray_info["value"]
            if ray_info["capped"]:
                warnings.append("自适应射线达包围盒对角线 1%% 上限,请检查高低模对应关系")
        except ValueError as e:
            ray = estimate_ray_distance(low_dims)
            ray_info = {"source": "fixed_fallback", "distance_p99": None,
                        "value": ray, "capped": False}
            warnings.append("自适应射线测量失败(%s),回退固定射线" % e)
  • Step 3: MT_SUMMARY 加 ray 字段

bl_bake.py main() 末尾,找到:

    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))

整体替换为:

    ray_info = dict(ray_info)
    ray_info["distance_p99"] = (None if ray_info["distance_p99"] is None
                                else round(ray_info["distance_p99"], 6))
    ray_info["value"] = round(ray_info["value"], 6)
    print("MT_SUMMARY " + json.dumps(
        {"stem": stem, "fbx": os.path.basename(out_fbx), "outputs": outputs,
         "size": size, "ray_distance": round(ray, 6), "ray": ray_info,
         "high_tris": len(high.data.polygons), "low_tris": len(low.data.polygons),
         "warnings": warnings}, ensure_ascii=False))
  • Step 4: model_bake.py 打印射线来源

model_bake.py 中,找到:

    print("== %s(高 %d 面 / 低 %d 面,射线距离 %.4f-> %s" %
          (s["stem"], s["high_tris"], s["low_tris"], s["ray_distance"], outdir))

其后新增:

    r = s.get("ray", {})
    p99 = "n/a" if r.get("distance_p99") is None else "%.4f" % r["distance_p99"]
    print("  射线: 来源=%s P99=%s 距离=%.4f%s" %
          (r.get("source", "?"), p99, r.get("value", s["ray_distance"]),
           "(已夹取)" if r.get("capped") else ""))
  • Step 5: 编译 + 回归

Run

python -m py_compile bl_bake.py model_bake.py
python -m unittest discover -s tests

Expected: 编译 exit 0;全部测试通过(本任务未改纯函数与既有测试)。

  • Step 6: 提交
git add -- Tools/ModelTranslator/bl_bake.py Tools/ModelTranslator/model_bake.py
git commit -m "ModelTranslator: use adaptive ray distance in bake (auto), report source"

提交信息末尾附一行:Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>


Task 3: 真机验证与文档

Files:

  • Modify: Tools/ModelTranslator/README.md
  • Create(临时诊断,验证后删除): Tools/ModelTranslator/_backhit_diag.py

Blender 路径:D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe。需先有 supergirl quad 低模(out_supergirl/supergirl_low.fbx;若无,先 python model_decimate.py "C:/Users/Administrator/Downloads/supergirl.fbx" --tris 10000 --reducer quad -o out_supergirl --blender <B>)。

  • Step 1: 写临时穿透诊断脚本

Create Tools/ModelTranslator/_backhit_diag.py

"""诊断"射线穿透打到背面":对低模每个面投射烘焙射线,判断命中高模面正/背。
用法:blender -b --factory-startup --python _backhit_diag.py -- <high> <low> <ray>"""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import bl_decimate as bd


def main():
    import bpy
    import mathutils
    from mathutils.bvhtree import BVHTree
    argv = sys.argv[sys.argv.index("--") + 1:]
    high_path, low_path, ray = argv[0], argv[1], float(argv[2])
    bpy.ops.wm.read_factory_settings(use_empty=True)
    bpy.ops.import_scene.fbx(filepath=high_path)
    high = bd.join_meshes([o for o in bpy.data.objects if o.type == 'MESH'])
    bd._clean_mesh(high, []); bd._triangulate(high)
    hv = [high.matrix_world @ v.co for v in high.data.vertices]
    hp = [tuple(p.vertices) for p in high.data.polygons]
    hn = [(high.matrix_world.to_3x3() @ p.normal).normalized() for p in high.data.polygons]
    bvh = BVHTree.FromPolygons(hv, hp, all_triangles=True)
    before = set(bpy.data.objects)
    bpy.ops.import_scene.fbx(filepath=low_path)
    low = [o for o in bpy.data.objects if o not in before and o.type == 'MESH'][0]
    bpy.ops.object.select_all(action='DESELECT'); low.select_set(True)
    bpy.context.view_layer.objects.active = low
    bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
    front = back = miss = 0
    for p in low.data.polygons:
        c = low.matrix_world @ p.center
        n = (low.matrix_world.to_3x3() @ p.normal).normalized()
        hit = bvh.ray_cast(c + n * ray, -n, ray * 3.0)
        if hit[0] is None:
            miss += 1
        elif hn[hit[2]].dot(n) >= 0:
            front += 1
        else:
            back += 1
    t = len(low.data.polygons)
    print("BH ray=%.5f 正面=%.2f%% 背面(穿透)=%.2f%% 落空=%.2f%%"
          % (ray, 100.0*front/t, 100.0*back/t, 100.0*miss/t))


main()
  • Step 2: 重烘 supergirl quad(自适应射线)并取射线值

Run

python model_bake.py "C:/Users/Administrator/Downloads/supergirl.fbx" out_supergirl/supergirl_low.fbx -o out_sg_adaptive --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe"

Expected:打印 射线: 来源=adaptive P99=... 距离=...;自适应距离明显小于旧固定值(低模对角线约 1.32,旧固定 ray=0.0264;自适应应在 0.001~0.01 量级)。记下打印的 距离=<ray> 值。

  • Step 3: 穿透率对比(旧固定 vs 新自适应)

用 Step 1 脚本分别在旧固定射线 0.0264 与 Step 2 的自适应 ray 上诊断:

B="D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe"
H="C:/Users/Administrator/Downloads/supergirl.fbx"
L="out_supergirl/supergirl_low.fbx"
"$B" -b --factory-startup --python _backhit_diag.py -- "$H" "$L" 0.0264 2>&1 | grep -a "BH"
"$B" -b --factory-startup --python _backhit_diag.py -- "$H" "$L" <Step2的自适应ray> 2>&1 | grep -a "BH"

Expected:固定 0.0264 时背面(穿透) ≈ 7%;自适应 ray 时背面(穿透)降到近 0(例如 <1%),落空不显著上升(<2%)。这证明脏斑成因被消除。

  • Step 4: 目视确认前裙脏斑消失

打开 out_sg_adaptive/supergirl/supergirl_color.png,对照旧 out_supergirl_bake/supergirl/supergirl_color.png:红裙区域深色涂抹应消失或大幅减轻。

  • Step 5: collapse 回归 + 显式覆盖

Run

python model_bake.py "C:/Users/Administrator/Downloads/supergirl.fbx" out_supergirl/supergirl_low.fbx -o out_sg_explicit --ray-distance 0.02 --blender "D:/tools/Blender/blender-5.0.0-windows-x64/blender.exe"

Expected:打印 射线: 来源=explicit ... 距离=0.0200;证明显式覆盖不测分离、行为不变。

  • Step 6: 更新 README

Tools/ModelTranslator/README.mdmodel_bake.py 段落,把射线距离描述:

`--size` 默认 2048`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);

改为:

`--size` 默认 2048`--samples` AO 采样默认 64**射线距离 `auto`(默认)按实测低↔高分离距离自适应**(分离 P99×1.5,夹在对角线 0.05%~1%),避免薄部件射线穿透打到背/内面产生深色脏斑——尤其 QR 四边低模偏离高模处;测量失败回退固定 2%,`--ray-distance` 显式覆盖;
  • Step 7: 删诊断脚本 + 最终验证

Run

rm -f Tools/ModelTranslator/_backhit_diag.py
cd Tools/ModelTranslator && python -m py_compile bl_bake.py model_bake.py && python -m unittest discover -s tests

Expected:编译 exit 0;全部测试通过。

  • Step 8: 检查暂存 + 提交文档

Run

git status --short
git diff --check

Expectedout_*/_backhit_diag.py 未被 stage;用户脏文件未动。

git add -- Tools/ModelTranslator/README.md docs/superpowers/specs/2026-07-23-adaptive-ray-bake-design.md docs/superpowers/plans/2026-07-23-adaptive-ray-bake.md
git commit -m "ModelTranslator: document adaptive ray bake"

提交信息末尾附一行:Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>


Self-Review 记录

  • Spec 覆盖:纯函数 percentile/adaptive_ray_distanceTask1)、_measure_separationTask2 Step1)、main auto 自适应 + 回退(Task2 Step2)、MT_SUMMARY.rayTask2 Step3)、model_bake 打印(Task2 Step4)、显式覆盖不变(Task2 Step2 的 != "auto" 分支)、真机穿透率验证(Task3)、READMETask3 Step6)——全覆盖。
  • 占位符:无 TBD/TODO;代码步骤含完整代码,Step3 的自适应 ray 值以"Step2 打印值"明确指代(真机运行时得到具体数)。
  • 类型一致adaptive_ray_distance 返回 {distance_p99,value,capped}Task1)与 main 消费 + 加 sourceTask2)、MT_SUMMARY 序列化(Task2 Step3)、model_bake 读取 r["distance_p99"]/value/source/cappedTask2 Step4)一致;percentile 定义(Task1)被 adaptive_ray_distance 调用一致;_measure_separation(low, high, limit) 定义(Task2 Step1)与调用(Task2 Step2)一致;low_dims 为 main 既有 tuple(low.dimensions)