ModelTranslator: document adaptive ray bake
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e66ba296c1
commit
6fe646310e
@@ -48,7 +48,7 @@ python model_translator.py out_bake/well1500/well1500.fbx # -> out/well1500/
|
||||
|
||||
- **model_decimate.py**:多 mesh 自动 join;**减面前清理网格**(按局部包围盒对角线相对焊接重合点 + 去退化面,给后端更干净的流形、减少被迫加的 seam);按 `--reducer` 后端减到 `--tris`(默认 Quad Remesher,失败自动回退 Decimate collapse;collapse 后端为 ±3%,最多 2 轮修正);旧 UV 全删后重展——默认锐边 seam 整岛展开(SLIM 展开后 `minimize_stretch` 松弛 + `average_islands_scale` 纹素均衡),质量门不达标(翻转 >2% 或重叠 >8%)先按更低 seam 角度扫描(55/45/35°)取过门且岛数最少者抢救,全失败才回退 Smart UV Project,`--unwrap smart` 可直接选投影式展开;**`--max-overlap 0.15` 放宽重叠门限**,让更高 seam 角度(岛更少)能过门被选中——岛数优先、可接受略高重叠时用(翻转门限始终严格);排布默认用 UVPackmaster 4 重排(只在最终选定 UV 上跑一次,旋转步进调细到 15° 以利薄斜条对齐提升利用率;UVPM 启发式搜索在 headless 下会随机崩溃引擎故未启用),UVPM4 不可用自动沿用内置 pack_islands 布局;输出 `<名>_low.fbx`(`-o` 改目录)与 `<名>_low_uv.png`(UV 线框观察图,便于人工查阅切分/排布/碎岛——Blender 抽 UV 几何,系统 Python 用 Pillow 绘制,因 headless 无 GPU 无法用 Blender 直接出 PNG),日志报 UV 岛数/利用率与实际生效的 seam 角度
|
||||
- **`--reducer collapse|quad`**(默认 `quad`):减面后端。`quad` 用 **Quad Remesher** 出规整四边拓扑(针对 collapse 的碎三角/UV 展开难痛点),headless 下**直接调 `Engine/xremesh.exe` 引擎子进程**(导出选中 mesh → 写 RetopoSettings.txt → 阻塞轮询 progress.txt → 导回 retopo.fbx),**绕开插件的 modal 操作符**(modal 在 `blender -b` 后台不执行,直接调操作符必然失败)。面数按 `--tris ÷ 2` 换算为目标四边形数,用 `ExactQuadCount=1` 尊重目标数(自适应模式在高细节硬表面模型上会把面数炸开数倍并连带 UV 重叠爆炸,故禁用);输出为近似面数(约 ±20%)。**任何失败——引擎缺失/超时/引擎报错/无有效输出/导回失败——都记警告并自动回退 collapse**,摘要 `reducer` 字段标 `quad->collapse`,绝不中断。**升级流程**:quad 后端先把高密度输入**预 collapse 到中等密度**(`--qr-input-cap`,默认 50000——高密度网格直接 QR 必破洞),再 QR,导回后 `fill_holes` 补掉薄部位小洞,最后**软水密门**(补洞后仍比输入多出超容忍的破损才判灾难性、回退 collapse)。quad 成功时**保留四边、不三角化**导出(FBX/烘焙/Unity 均支持四边;这修掉了旧版 quad 被三角化的问题)。`QR_ENGINE` 环境变量可覆盖引擎路径(**权威**:显式设了就以它为准,无效即视同缺失走回退)。QR 输出为近似面数(约 ±20%,四边口径 `--tris÷2`);实测有机角色可保四边(如 100 万面 supergirl → 约 5000 四边、开边≤3),硬表面直边仍会被 QR 波动化(那类模型用 collapse)
|
||||
- **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% 打警告;导入后自动应用物体变换——未应用缩放的 FBX(如 cm 单位导出的 scale=0.01)会把射线距离缩到近零导致大面积烘空(脏色);固定 `-t 1` 单线程跑 Blender——5.0 的 selected-to-active 射线求交多线程有竞争,会随机 EXCEPTION_ACCESS_VIOLATION 崩溃
|
||||
- **model_bake.py**:Cycles selected-to-active 烘焙。normal(切线空间 OpenGL +Y)/ao 直接烘;color/metallic/roughness 把高模 Principled 对应输入接 Emission 烘 EMIT。`--size` 默认 2048,`--samples` AO 采样默认 64;**射线距离 `auto`(默认)按实测低↔高分离距离自适应**(分离 P99×1.5,夹在包围盒对角线 0.05%~1%),比旧的固定 2% 小很多,避免薄部件(如裙子)射线穿透打到背/内面产生深色脏斑——尤其 QR 四边低模偏离高模处(实测 supergirl 穿透面 7%→3.2%);测量失败回退固定 2%,`--ray-distance` 显式覆盖;低模无 UV 会报错,高低模包围盒差超 10% 打警告;导入后自动应用物体变换——未应用缩放的 FBX(如 cm 单位导出的 scale=0.01)会把射线距离缩到近零导致大面积烘空(脏色);固定 `-t 1` 单线程跑 Blender——5.0 的 selected-to-active 射线求交多线程有竞争,会随机 EXCEPTION_ACCESS_VIOLATION 崩溃
|
||||
- 输出命名 `<名>_color/normal/metallic/roughness/ao.png`,正好命中转换器的纯网格贴图命名约定
|
||||
|
||||
## 转换规则
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
# 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_bake` 的 `auto` 射线从固定"低模包围盒对角线 2%"改为按实测低↔高分离距离自适应,消除薄部件(如裙子)烘焙射线穿透打到背/内面造成的深色脏斑。
|
||||
|
||||
**Architecture:** 纯函数 `percentile` / `adaptive_ray_distance` 进 `bl_bake.py` 可 TDD;Blender 侧加 `_measure_separation`(高模 BVH 测低模面心最近距)并在 `main` 的 auto 分支替换固定射线,失败回退固定 2%。显式 `--ray-distance` 仍覆盖。
|
||||
|
||||
**Tech Stack:** Python 标准库、Blender 5.0 `bpy`/`mathutils`、`unittest`、现有 headless Blender runner。
|
||||
|
||||
设计依据:`docs/superpowers/specs/2026-07-23-adaptive-ray-bake-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 不加外部依赖。
|
||||
- 显式 `--ray-distance` 优先;自适应失败回退固定 2%,绝不中断烘焙。
|
||||
- 不改烘焙 pass 结构/输出命名/贴图协议;`MT_SUMMARY` 只增 `ray` 字段。
|
||||
- 纯逻辑 TDD;Blender 侧由真机 smoke 覆盖。
|
||||
- 只 stage 本计划源码/文档;不碰用户脏工作区与 `out_*` 产物。
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify `Tools/ModelTranslator/bl_bake.py`:加 `percentile`、`adaptive_ray_distance`、常量;加 `_measure_separation`;`main` auto 分支自适应 + `MT_SUMMARY.ray`。
|
||||
- Modify `Tools/ModelTranslator/tests/test_bl_bake.py`:`percentile`、`adaptive_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.py` 的 `if __name__ == "__main__":` 之前插入:
|
||||
|
||||
```python
|
||||
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`):
|
||||
```bash
|
||||
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.py` 的 `DEFAULT_RAY_PCT = 0.02` 那行之后新增常量:
|
||||
```python
|
||||
RAY_SAFETY = 1.5 # 自适应射线安全系数(分离 P99 × 此值)
|
||||
RAY_LOWER_PCT = 0.0005 # 射线下限 = 包围盒对角线 * 0.05%
|
||||
RAY_UPPER_PCT = 0.01 # 射线上限 = 包围盒对角线 * 1%
|
||||
```
|
||||
|
||||
在 `estimate_ray_distance` 函数之后新增两个纯函数:
|
||||
```python
|
||||
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:
|
||||
```bash
|
||||
python -m unittest tests.test_bl_bake -v
|
||||
python -m unittest discover -s tests
|
||||
```
|
||||
Expected: 新测试全过;现有 `TestEstimateRayDistance` 等仍过;全量零失败。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
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 内执行 ----------------` 区内)插入:
|
||||
```python
|
||||
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()` 中,找到这一行:
|
||||
```python
|
||||
ray = estimate_ray_distance(low_dims) if ray_arg == "auto" else float(ray_arg)
|
||||
```
|
||||
整体替换为:
|
||||
```python
|
||||
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()` 末尾,找到:
|
||||
```python
|
||||
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))
|
||||
```
|
||||
整体替换为:
|
||||
```python
|
||||
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` 中,找到:
|
||||
```python
|
||||
print("== %s(高 %d 面 / 低 %d 面,射线距离 %.4f)-> %s" %
|
||||
(s["stem"], s["high_tris"], s["low_tris"], s["ray_distance"], outdir))
|
||||
```
|
||||
其后新增:
|
||||
```python
|
||||
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:
|
||||
```bash
|
||||
python -m py_compile bl_bake.py model_bake.py
|
||||
python -m unittest discover -s tests
|
||||
```
|
||||
Expected: 编译 exit 0;全部测试通过(本任务未改纯函数与既有测试)。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
```python
|
||||
"""诊断"射线穿透打到背面":对低模每个面投射烘焙射线,判断命中高模面正/背。
|
||||
用法: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:
|
||||
```bash
|
||||
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 上诊断:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
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.md` 的 `model_bake.py` 段落,把射线距离描述:
|
||||
```markdown
|
||||
`--size` 默认 2048,`--samples` AO 采样默认 64,射线距离默认低模包围盒对角线 2%(`--ray-distance` 覆盖);
|
||||
```
|
||||
改为:
|
||||
```markdown
|
||||
`--size` 默认 2048,`--samples` AO 采样默认 64;**射线距离 `auto`(默认)按实测低↔高分离距离自适应**(分离 P99×1.5,夹在对角线 0.05%~1%),避免薄部件射线穿透打到背/内面产生深色脏斑——尤其 QR 四边低模偏离高模处;测量失败回退固定 2%,`--ray-distance` 显式覆盖;
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 删诊断脚本 + 最终验证**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
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:
|
||||
```bash
|
||||
git status --short
|
||||
git diff --check
|
||||
```
|
||||
Expected:`out_*`/`_backhit_diag.py` 未被 stage;用户脏文件未动。
|
||||
```bash
|
||||
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_distance(Task1)、`_measure_separation`(Task2 Step1)、main auto 自适应 + 回退(Task2 Step2)、MT_SUMMARY.ray(Task2 Step3)、model_bake 打印(Task2 Step4)、显式覆盖不变(Task2 Step2 的 `!= "auto"` 分支)、真机穿透率验证(Task3)、README(Task3 Step6)——全覆盖。
|
||||
- **占位符**:无 TBD/TODO;代码步骤含完整代码,Step3 的自适应 ray 值以"Step2 打印值"明确指代(真机运行时得到具体数)。
|
||||
- **类型一致**:`adaptive_ray_distance` 返回 `{distance_p99,value,capped}`(Task1)与 main 消费 + 加 `source`(Task2)、MT_SUMMARY 序列化(Task2 Step3)、model_bake 读取 `r["distance_p99"]/value/source/capped`(Task2 Step4)一致;`percentile` 定义(Task1)被 `adaptive_ray_distance` 调用一致;`_measure_separation(low, high, limit)` 定义(Task2 Step1)与调用(Task2 Step2)一致;`low_dims` 为 main 既有 `tuple(low.dimensions)`。
|
||||
@@ -0,0 +1,108 @@
|
||||
# model_bake 自适应射线距离 设计
|
||||
|
||||
日期:2026-07-23
|
||||
|
||||
## 背景与动机
|
||||
|
||||
真机诊断确认:`--reducer quad` 低模烘焙时,前裙等**薄部件**出现深色"脏斑"。量化结果:
|
||||
supergirl quad 低模 **7.06% 的面**,selected-to-active 烘焙射线**穿透薄表面、打到背/内面**
|
||||
(命中法线与低模面法线反向),把里面的深色烘到正面。
|
||||
|
||||
- **机制**:`bl_bake._bake_pass` 用固定 `cage_extrusion=ray`、`max_ray_distance=ray*2`,
|
||||
其中 `ray = 低模包围盒对角线 * 2%`(`DEFAULT_RAY_PCT`)。对薄部件,`max_ray`(=4% 对角线)
|
||||
远大于裙子厚度 → 射线穿透。
|
||||
- **为何 quad 特有**:QR 重拓扑低模偏离高模表面;collapse 用原始顶点贴在高模上,射线第一下
|
||||
即中正面,故干净。
|
||||
- **为何自适应能修**:7% 穿透面的面心其实离**正面**很近(分离≈0,只是射线过长冲到背面)。
|
||||
按实测分离距离把射线调小后,射线先命中最近的正面 → 穿透面转为正面命中,脏斑消除。
|
||||
|
||||
固定 2% 射线是问题根源。改为**按实测低↔高分离距离**自适应,是 `2026-07-16-model-bake`
|
||||
计划早已设计但未落地的 `adaptive_ray_distance` 思路,本 spec 聚焦落地这一块。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不加外部依赖(Python 标准库 + `mathutils`)。
|
||||
- 显式 `--ray-distance` 仍优先、覆盖自适应。
|
||||
- 自适应失败(无分离样本/包围盒退化)**降级回退**到现有固定 2%,绝不中断烘焙。
|
||||
- 不改烘焙 pass 结构、输出命名、贴图内容协议;`MT_SUMMARY` 只增字段不改既有。
|
||||
- 纯逻辑(percentile、adaptive_ray_distance)与 Blender 操作分离,纯逻辑 TDD。
|
||||
- 不做 `2026-07-16` 计划的法线/UV 修复;不加 cage 物体支持(YAGNI)。
|
||||
|
||||
## 架构(全在 `bl_bake.py`,collapse/quad 通用)
|
||||
|
||||
### 纯函数(可单测,不 import bpy)
|
||||
|
||||
- `percentile(values, q) -> float`:插值分位数;空列表或 q∉[0,1] 抛 `ValueError`。
|
||||
- 常量 `RAY_SAFETY=1.5`、`RAY_LOWER_PCT=0.0005`、`RAY_UPPER_PCT=0.01`。
|
||||
- `adaptive_ray_distance(distances, bbox_dims) -> dict`:
|
||||
- `diagonal = sqrt(sum(d^2))`;`diagonal<=0` 抛 `ValueError`。
|
||||
- `p99 = percentile(distances, 0.99)`;`raw = max(p99*RAY_SAFETY, diagonal*RAY_LOWER_PCT)`;
|
||||
`value = min(raw, diagonal*RAY_UPPER_PCT)`。
|
||||
- 返回 `{"distance_p99": p99, "value": value, "capped": raw > diagonal*RAY_UPPER_PCT}`。
|
||||
- 即:射线 = 分离 P99×1.5,夹在包围盒对角线 0.05%~1% 之间(旧固定值是 2%,故必然更小)。
|
||||
|
||||
### Blender 侧
|
||||
|
||||
- `_measure_separation(low, high, limit=10000) -> list[float]`:
|
||||
- 高模建 `BVHTree.FromPolygons(高模世界坐标顶点, 多边形, all_triangles=False)`。
|
||||
- 低模面若 ≤limit 全取,否则均匀采样 limit 个面;对每个面心(世界坐标)求 `bvh.find_nearest`
|
||||
的距离,收集为距离数组(丢弃无命中的)。
|
||||
- 返回距离列表(可能为空)。
|
||||
|
||||
### main 集成
|
||||
|
||||
- `ray_arg == "auto"` 分支:
|
||||
- `dists = _measure_separation(low, high)`。
|
||||
- 若 `dists` 非空:`info = adaptive_ray_distance(dists, tuple(low.dimensions))`;`ray = info["value"]`;
|
||||
`ray_source = "adaptive"`;`info["capped"]` 为真时记警告"自适应射线达对角线 1% 上限,检查高低模对应"。
|
||||
- 若 `dists` 空 或 `adaptive_ray_distance` 抛异常:`ray = estimate_ray_distance(low_dims)`(回退固定 2%),
|
||||
记警告"分离测量失败,回退固定射线",`ray_source = "fixed_fallback"`。
|
||||
- `ray_arg != "auto"`:`ray = float(ray_arg)`,`ray_source = "explicit"`(不变)。
|
||||
- `_bake_pass` 的 `cage_extrusion=ray, max_ray_distance=ray*2.0` **关系不变**(ray 已是自适应小值)。
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
import high/low --apply transform-->
|
||||
ray_arg=auto: high 建 BVH -> 低模面心最近距离数组 -> adaptive_ray_distance(P99*1.5, clamp[0.05%,1%])
|
||||
(空/异常 -> 回退固定 2%)
|
||||
ray_arg=显式: float(ray_arg)
|
||||
--> 各 pass _bake_pass(cage=ray, max_ray=ray*2) --> PNG + MT_SUMMARY{ray_distance, ray:{source,p99,value,capped}}
|
||||
```
|
||||
|
||||
## MT_SUMMARY 变化
|
||||
|
||||
在现有 `"ray_distance": round(ray,6)` 基础上新增 `"ray"` 字段:
|
||||
`{"source": "adaptive|explicit|fixed_fallback", "distance_p99": <float|None>, "value": <float>, "capped": <bool>}`。
|
||||
`model_bake.py` 打印一行射线信息(来源/P99/距离/是否夹取)。
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 情况 | 处理 |
|
||||
|---|---|
|
||||
| 低模无面 / BVH 无命中 → 分离数组空 | 回退固定 2% 射线,记警告 |
|
||||
| 低模包围盒对角线为 0(退化) | `adaptive_ray_distance` 抛 ValueError → 回退固定 2%,记警告 |
|
||||
| 显式 `--ray-distance` | 直接用,不测分离 |
|
||||
| 自适应值被夹到 1% 上限 | 记警告(提示高低模可能不匹配),仍用夹取值 |
|
||||
|
||||
## 测试
|
||||
|
||||
- **`tests/test_bl_bake.py`**(纯函数):
|
||||
- `percentile`:`[0,10]` q=0.25 → 2.5;空列表/q=1.1 抛 ValueError。
|
||||
- `adaptive_ray_distance`:
|
||||
- 贴合样本 `[0.002]*20, bbox(1,0,0)` → value=0.003(P99×1.5,未夹)、capped=False。
|
||||
- 全 0 分离 → value=下限 0.0005、capped=False(下限保护)。
|
||||
- 大分离 `[0.02]`,bbox(1,0,0) → value=0.01(夹到 1% 上限)、capped=True。
|
||||
- 零包围盒 → ValueError。
|
||||
- **回归**:`python -m unittest discover -s tests` 全绿。
|
||||
- **真机 smoke**:
|
||||
- 重烘 supergirl quad → 用 backhit 诊断(临时脚本)验证**穿透面 7% → 近 0**,前裙脏斑消失,
|
||||
且无大面积落空(黑像素不显著增加)。
|
||||
- collapse 回归:射线 source=adaptive、烘焙正常,贴图与升级前相当。
|
||||
- 显式 `--ray-distance 0.02` → source=explicit,行为不变。
|
||||
|
||||
## 明确不做(YAGNI)
|
||||
|
||||
- 不做法线/UV 自动修复(`2026-07-16` 计划另一块)。
|
||||
- 不加 cage 物体支持、不加 per-face 射线(单一自适应全局值已解决穿透)。
|
||||
- 不新增 CLI 旋钮(复用现有 `--ray-distance` 覆盖)。
|
||||
Reference in New Issue
Block a user