新增 mcfold/fine.py:把 M3 状态层的区块灌进细渲染器 - chunks_to_sections:铺平顺序 = (y<<8)|(z<<4)|x(与 Java PalettedContainer 一致) ★ 错一位不报错、只是地形静默错位 ⇒ 判据用构造数据打这个点 - 缺失的 section **省略**而非填 0(0=空气,把「没数据」渲染成空洞) - render_from_state:单次调用,失败全部返回 ok:false + why + how_to 新增 mcfold/photo.py:capture_photo - propose_shots 用**粗渲染观察**生成机位候选(正面/斜侧/俯视/远景/当前位置), 每个都带「为什么选它」的依据;候选阶段不出图(省时省钱) - 构图好坏是审美判断=决策,工具不替 agent 选 ★ 关键发现:桥接比预想的干净 原以为要把 state id 反算成 name[props],但 Java 侧 set_blocks 收的 **本来就是 state id**(走官方 LevelChunk API),而 state id 本身含属性 ⇒ 无损、无需转换。 修的真 bug: 1. ★★ HUD 没隐藏:桥层 RenderBackend.set_view 早有 hide_gui (→ 官方 options.hideGui),但 WorldRenderer **没有这个入口** ⇒ AttributeError 被 except: pass 吞掉 ⇒ 出图带生命条/饥饿/物品栏, 而返回值仍写 hide_gui: True(自相矛盾) 教训:桥层有能力 ≠ 上层有入口;except: pass 让「以为设了」与 「实际没设」无法区分。现在 hide_gui 设不上就显式失败。 2. 俯视机位朝向不自洽(水平 yaw 退化却配写死的 pitch)→ 3D dot=0.82。 改为由位置向量反算 yaw/pitch;正面/斜侧/远景同样修正。 3. inject_world 的 ok 自相矛盾(无区块时 ok=True 却说「没有可渲染的区块」) 4. 缺失 section 被填 0 冒充空气 实测出图:854×480、1902 种颜色,地形/天空/月亮/草地纹理正确。 判据 17 项;2 项变异全被抓。 ★ 「机位朝向」判据我第一版只比水平分量 ⇒ 俯视机位恒为 0 会判红, 改为完整 3D 点积(dot > 0.99)。 200 项全通过。
213 lines
8.4 KiB
Python
213 lines
8.4 KiB
Python
"""M9 第一个用例:`capture_photo()` —— 粗搜机位 → 细出图。
|
||
|
||
## 这个用例验证什么
|
||
|
||
plan §5 的原始动机(用户场景):
|
||
> 「我和某个景物的合照」—— agent 先用**粗渲染**找好角度,
|
||
> 再用**细渲染**出真实照片。
|
||
|
||
它之所以排在最后,是因为它**同时验证两个子系统**:
|
||
- 粗渲染(M6)能不能给出**可用的机位候选**(不是拍脑袋的坐标)
|
||
- 细渲染(M8)能不能把**服务端真状态**渲成图
|
||
|
||
## 关键设计:机位从哪来
|
||
|
||
**不是**让 agent 拍脑袋报坐标,而是:
|
||
1. 先问 M6:目标可见吗?周围能站吗?地面在哪?
|
||
2. 由这些**观察**生成若干机位候选(正面/侧面/俯视/远景…)
|
||
3. 逐个(或挑最优的)用细渲染出图
|
||
4. 把「机位是怎么来的」一并返回 —— agent 能判断可不可信
|
||
|
||
## 为什么不追求「自动选最优机位」
|
||
|
||
构图好坏是**审美判断**,属决策。按 mcfold 纪律(工具不决策),
|
||
本模块给出**结构化的机位候选 + 每个机位的观察依据**,
|
||
让 agent 自己选(可以 `shot_index` 指定,或全都要)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import os
|
||
from typing import Any
|
||
|
||
from . import rough
|
||
from .state import WorldState
|
||
|
||
|
||
def _facing_yaw(tx: float, tz: float, fx: float, fz: float) -> float:
|
||
"""从机位看向目标所需的 yaw(MC 约定:0=+Z)。"""
|
||
dx, dz = tx - fx, tz - fz
|
||
return (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
|
||
|
||
|
||
def propose_shots(
|
||
world: WorldState,
|
||
target: tuple[float, float, float],
|
||
*,
|
||
radius: int = 3,
|
||
max_shots: int = 6,
|
||
) -> dict[str, Any]:
|
||
"""用**粗渲染的观察**生成机位候选(不渲染,只观察)。
|
||
|
||
每个候选都带**为什么选它**(依据),便于 agent 判断与追问。
|
||
"""
|
||
p = world.player
|
||
tx, ty, tz = target
|
||
ground = world.block_at(int(tx), int(ty) - 1, int(tz))["name"]
|
||
base_y = ty - 0.6 # 略微下蹲的机位高度,比站在格心自然
|
||
|
||
cands: list[dict[str, Any]] = []
|
||
|
||
def add(name: str, fx: float, fy: float, fz: float, pitch: float,
|
||
why: str, dist: float | None = None) -> None:
|
||
d = dist if dist is not None else math.dist((fx, fy, fz), (tx, ty, tz))
|
||
cands.append({
|
||
"index": len(cands),
|
||
"name": name,
|
||
"camera": [round(fx, 2), round(fy, 2), round(fz, 2),
|
||
round(_facing_yaw(tx, tz, fx, fz), 1), round(pitch, 1)],
|
||
"distance": round(d, 2),
|
||
"why": why,
|
||
})
|
||
|
||
def add_at(name: str, fx: float, fy: float, fz: float,
|
||
why: str, dist: float | None = None) -> None:
|
||
"""**由位置反算** yaw/pitch,保证真的看向目标。
|
||
|
||
★ 为什么需要它:俯视机位在目标**正上方 4 格**,此时
|
||
水平方向的 yaw 已经退化(dx/dz ≈ 0,方位无意义),
|
||
真正的方向是「几乎垂直向下」。
|
||
我先前写死 `pitch=-35` 配水平算出的 yaw ⇒ 两者**不自洽**
|
||
(实测 dot=0.82,相机没真正对着目标)。
|
||
⇒ 只要机位不在目标同一水平面,就必须由向量反算朝向。
|
||
"""
|
||
dx, dy, dz = tx - fx, ty - fy, tz - fz
|
||
horiz = math.hypot(dx, dz)
|
||
yaw = (math.degrees(math.atan2(dz, dx)) - 90.0) % 360.0
|
||
pitch = -math.degrees(math.atan2(dy, horiz)) if (horiz or dy) else 0.0
|
||
d = dist if dist is not None else math.sqrt(dx * dx + dy * dy + dz * dz)
|
||
cands.append({
|
||
"index": len(cands), "name": name,
|
||
"camera": [round(fx, 2), round(fy, 2), round(fz, 2),
|
||
round(yaw, 1), round(pitch, 1)],
|
||
"distance": round(d, 2), "why": why,
|
||
})
|
||
|
||
# ① 目标正前方一格(经典正面照)
|
||
dx, dz = tx - p.x, tz - p.z
|
||
n = math.hypot(dx, dz) or 1.0
|
||
ux, uz = dx / n, dz / n
|
||
fx, fz = tx - ux * 3.0, tz - uz * 3.0
|
||
fx, fz = int(round(fx)), int(round(fz))
|
||
if world.world.registry is not None and world.block_at(fx, int(ty) - 1, fz)["name"]:
|
||
add_at("正面", fx, base_y, fz,
|
||
f"站在目标正前方 3 格({fx},{int(ty)-1},{fz} 有 {ground} 支撑)")
|
||
else:
|
||
add_at("正面(悬空)", fx, base_y, fz, "目标正前方 3 格(脚下无支撑,可能悬空)")
|
||
|
||
# ② 侧面 45°
|
||
sx, sz = int(round(tx - uz * 3.0 + ux * 3.0)), int(round(tz + ux * 3.0 + uz * 3.0))
|
||
if world.block_at(sx, int(ty) - 1, sz)["name"]:
|
||
add_at("斜侧", sx, base_y, sz,
|
||
f"侧 45°、距离 3 格({sx},{int(ty)-1},{sz} 有支撑)")
|
||
else:
|
||
add_at("斜侧(悬空)", sx, base_y, sz, "侧 45°(脚下无支撑)")
|
||
|
||
# ③ 俯视(看全景)—— 用 add_at:机位在目标上方,水平朝向退化
|
||
add_at("俯视", tx, ty + 4.0, tz + 0.01,
|
||
"目标正上方 4 格俯视(朝向由位置反算,保证真的对着目标)")
|
||
|
||
# ④ 远景(若视线通)
|
||
far_x, far_z = int(round(tx - ux * 8.0)), int(round(tz - uz * 8.0))
|
||
vis = rough.can_see(world, (fx, base_y, fz))
|
||
add_at("远景", far_x, base_y, far_z,
|
||
f"8 格外远景(近处视线:{vis['reason']})")
|
||
|
||
# ⑤ 玩家当前机位(零成本:就在这儿)
|
||
add("当前位置", p.x, p.y + 1.62, p.z, p.pitch,
|
||
f"就用你现在的视角({p.yaw:.0f}°/{p.pitch:.0f}°)")
|
||
|
||
return {
|
||
"ok": True,
|
||
"target": list(target),
|
||
"shots": cands[:max_shots],
|
||
"count": min(len(cands), max_shots),
|
||
"target_ground": ground,
|
||
"source": "粗渲染观察(M6 raycast/can_see/block_at),未出图",
|
||
"note": "机位是从**观察**推出的候选,不是构图判断;"
|
||
"用 shot_index 选一个,或对每个都出图再挑",
|
||
}
|
||
|
||
|
||
def capture_photo(
|
||
world: WorldState,
|
||
target: tuple[float, float, float] | None = None,
|
||
*,
|
||
store: str,
|
||
shot_index: int | None = None,
|
||
out_dir: str | None = None,
|
||
name: str = "photo",
|
||
hide_gui: bool = True,
|
||
frames: int = 40,
|
||
render_distance: int = 6,
|
||
) -> dict[str, Any]:
|
||
"""【M9】拍照:粗搜机位 → 细出图。
|
||
|
||
## 参数
|
||
- `target` 目标坐标;省略则以**玩家朝向的前方**为目标
|
||
- `shot_index` 选第几个机位;省略则**每个机位都出一张**
|
||
- `store` 内容根(含 `.mcfold` 渲染产物)
|
||
|
||
## 返回
|
||
```python
|
||
{"ok": True, "shots": [ {...,"image": "...", "ok": True}, ... ],
|
||
"shot_options": [...], "target": [...]}
|
||
```
|
||
出图失败的机位**不丢弃**(带上 `ok:false, why`),
|
||
这样 agent 能知道「哪些角度失败、为什么」。
|
||
"""
|
||
from .fine import render_from_state # 延迟导入
|
||
|
||
p = world.player
|
||
if target is None:
|
||
# 用当前朝向的前方当目标(粗渲染的 raycast 就是这么算的)
|
||
v = rough._look_vector(p.yaw, p.pitch)
|
||
d = 6.0
|
||
target = (p.x + v[0] * d, p.y + 1.62 + v[1] * d, p.z + v[2] * d)
|
||
|
||
prop = propose_shots(world, target)
|
||
if not prop["shots"]:
|
||
return {"ok": False, "why": "没能提出任何机位候选",
|
||
"how_to": "确认状态层有区块(pump 几秒)", "target": list(target)}
|
||
|
||
out_dir = out_dir or os.path.join(
|
||
os.environ.get("MCFOLD_RUNTIME", "/tmp"), "photos")
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
|
||
idxs = [shot_index] if shot_index is not None else \
|
||
[s["index"] for s in prop["shots"]]
|
||
results: list[dict[str, Any]] = []
|
||
for i in idxs:
|
||
if not (0 <= i < len(prop["shots"])):
|
||
return {"ok": False, "why": f"shot_index {i} 越界(共 {len(prop['shots'])} 个机位)",
|
||
"how_to": f"可用 index:0..{len(prop['shots'])-1}",
|
||
"shot_options": prop["shots"]}
|
||
s = prop["shots"][i]
|
||
out = os.path.join(out_dir, f"{name}_{i}.png")
|
||
r = render_from_state(
|
||
world, store=store, camera=tuple(s["camera"]), out=out,
|
||
hide_gui=hide_gui, frames=frames, render_distance=render_distance)
|
||
results.append({**s, **r})
|
||
|
||
ok_any = any(r.get("ok") for r in results)
|
||
return {
|
||
"ok": ok_any,
|
||
"target": list(target),
|
||
"shots": results,
|
||
"shot_options": prop["shots"],
|
||
"target_ground": prop["target_ground"],
|
||
"out_dir": out_dir,
|
||
"note": (prop["note"] if ok_any else
|
||
"所有机位都出图失败;见每个 shot 的 why"),
|
||
}
|