Files
agentmc/mcfold/fine.py
JianFeeeee 4980d6e81f M8/M9:细渲染接服务端真状态 + capture_photo 拍照用例
新增 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 项全通过。
2026-09-26 13:52:33 +08:00

248 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""M8:把**服务端真状态**喂给细渲染器(细渲染单次调用,出图)。
## 与 M6(粗渲染)的关系
plan §1d 的分工:粗渲染是**常驻感知**(纯 Python、无 JVM、毫秒级),
细渲染是**按需出图**(起 JVM + 官方渲染器 + 光影,20-40s)。
**两者不做像素级互相预测**(plan §4.5 明确:那不是刚需)。
它们的唯一交集是 M9 的拍照用例 —— 那里是**用法**上的配合,不是正确性依赖。
## 为什么 M8 现在才做
细渲染早就能跑(Iris 光影 + 地形,`colors=113546` 实测),
但那一直是**注入假数据**。M2/M3 通了之后,真状态可以直接灌进去:
M3 的 `Chunk` 手里就是 state id,而 Java 侧 `set_blocks` 收的也正是 state id
(走官方 `LevelChunk` API)⇒ **不需要在 Python 反算 `name[props]`**。
## 一条硬纪律
细渲染的失败**必须对 agent 可读**:
JVM 起不来、显存不够、chunk 没加载、认不出的方块 ——
都要返回 `{ok:false, why, how_to}`,而不是抛异常或给一张空图。
"""
from __future__ import annotations
import os
from typing import Any
from .chunk import Chunk
from .state import WorldState
# ---------- 状态层 → 渲染器 ----------
def chunks_to_sections(ch: Chunk) -> dict[int, list[int]]:
"""M3 的 `Chunk` → 渲染器要的 `{sectionIndex: [4096 个 state id]}`。
## 索引顺序
Java 侧按 **(y, z, x)** 线性序遍历 4096 格(`idx = (y<<8)|(z<<4)|x`),
与 MC 的 PalettedContainer 布局一致(x 最快、y 最慢)。
我方必须**按同一顺序**铺平,否则地形会错位(表现为"地形乱了")。
## 边界
- `bits_per_block == 0` 的 section(单一值)会被 `state_id_at` 正常处理,
不用特殊分支。
- 区块高度不足 384 时缺失的 section **不产出**(由 Java 侧留空),
不要填 0(0 是空气,会把"没数据"渲染成"空气")。
"""
sections: dict[int, list[int]] = {}
for sec in ch.sections:
if sec is None:
continue
idx = sec.y_index
if idx is None or idx < 0:
continue
cells: list[int] = []
for y in range(16):
for z in range(16):
for x in range(16):
cells.append(sec.state_id_at(x, y, z))
sections[idx] = cells
return sections
def inject_world(
renderer,
world: WorldState,
*,
columns: list[tuple[int, int]] | None = None,
) -> dict[str, Any]:
"""把状态层已加载的区块灌进渲染器。
返回 `{ok, injected, skipped, ...}`。**不抛异常** ——
渲染失败对 agent 必须可读。
"""
cols = columns if columns is not None else sorted(world.world.columns())
injected: list[tuple[int, int]] = []
skipped: list[dict[str, Any]] = []
empty = 0
for cx, cz in cols:
ch = world.world.chunks.get((cx, cz))
if ch is None:
continue
try:
secs = chunks_to_sections(ch)
except Exception as e:
skipped.append({"column": [cx, cz], "why": f"{type(e).__name__}: {e}"})
continue
if not secs:
empty += 1
continue
try:
renderer.bridge.set_blocks(cx, cz, secs)
renderer.chunks[(cx, cz)] = ch
renderer._loaded.add((cx, cz))
injected.append((cx, cz))
except Exception as e:
skipped.append({"column": [cx, cz],
"why": f"写入失败 {type(e).__name__}: {e}"})
# ★ ok 的含义要统一:「这一批区块是否成功灌进去了」。
# 我先前写成 `bool(injected) or not cols` ⇒ **一个区块都没有时
# 返回 ok=True**,而 note 却在说「没有可渲染的区块」——自相矛盾
# (agent 会以为成功,拿到 0 张图)。
return {
"ok": bool(injected),
"injected": len(injected),
"requested": len(cols),
"columns": [list(c) for c in injected],
"skipped": skipped,
"empty": empty,
"note": ("区块已灌入渲染器" if injected else
"没有可渲染的区块:先在服务端附近走动让区块加载"),
}
# ---------- 细渲染(单次调用) ----------
#: 默认的细渲染环境变量(可用 env 覆盖)
_ENV = {
"MCFOLD_RENDER_CLASSPATH": None, # 由 store 自动拼
"MCFOLD_NATIVES_DIR": None,
"DISPLAY": ":98",
}
def _prepare_env(store: str) -> dict[str, str]:
"""把渲染所需的环境变量准备好(不覆盖已有设置)。"""
env = dict(os.environ)
dot = os.path.join(store, ".mcfold")
cp_file = os.path.join(dot, "classpath.txt")
if not env.get("MCFOLD_RENDER_CLASSPATH") and os.path.exists(cp_file):
with open(cp_file, "r", encoding="utf-8") as f:
cp = f.read().strip().split("\n")
parts = [p for p in cp if p]
parts.append(os.path.join(dot, "client-named.jar"))
parts.append(os.path.join(dot, "classes"))
env["MCFOLD_RENDER_CLASSPATH"] = ":".join(parts)
if not env.get("MCFOLD_NATIVES_DIR"):
natives = os.path.join(dot, "natives")
if os.path.isdir(natives):
env["MCFOLD_NATIVES_DIR"] = natives
env.setdefault("DISPLAY", ":98")
return env
def render_from_state(
world: WorldState,
*,
store: str,
camera: tuple[float, float, float, float, float] | None = None,
out: str | None = None,
hide_gui: bool = True,
frames: int = 40,
render_distance: int = 6,
timeout: float = 240.0,
) -> dict[str, Any]:
"""【M8】用**服务端真状态**出一张图(单次调用)。
这是 M9 `capture_photo` 的底座,也可单独调用。
## 为什么「单次调用」而不是常驻
JVM 启停成本高(20-40s),但细渲染是**按需**的
(要图 / 要精细判断时才用)⇒ 起→渲→关最合适。
日常感知用 M6 粗渲染(毫秒级、常驻)。
## 失败必须可读
起不来 JVM / 显存不足 / 没有区块 —— 全部返回
`{ok:false, why, how_to}`,不抛异常(agent 看不到 traceback)。
"""
os.environ.update(_prepare_env(store))
from .render import WorldRenderer # 延迟导入:没渲染环境也不影响其它功能
cols = sorted(world.world.columns())
if not cols:
return {"ok": False,
"why": "没有任何已加载区块,出图会是一张空图",
"how_to": "先在服务端附近走动(pump 几秒)让区块加载,再试"}
if camera is None:
p = world.player
camera = (p.x, p.y + 1.62, p.z, p.yaw, p.pitch)
notes: list[str] = []
try:
renderer = WorldRenderer.open()
except Exception as e:
return {"ok": False, "why": f"渲染器启动失败:{type(e).__name__}: {e}",
"how_to": "检查 store 下的 .mcfold 产物(classpath.txt / natives)与 DISPLAY"}
try:
inj = inject_world(renderer, world, columns=cols)
if not inj["injected"]:
return {"ok": False, "why": inj["note"],
"how_to": inj.get("skipped") or "确认状态层有区块",
"inject": inj}
try:
renderer.set_render_distance(render_distance)
except Exception as e:
# 不是致命(只是渲染距离可能不对),但**要说出来** ——
# 静默 pass 会让调用方以为设成功了。
notes.append(f"render_distance 未生效:{type(e).__name__}")
# ★ HUD 必须关(plan 明确要求截图隐藏 HUD)。
# 这里**故意不吞异常**:hide_gui 是截图质量的硬要求,
# 设不上就该让调用方知道(早先我用 except: pass 掩盖了
# 「方法根本不存在」这个真 bug,出图带着 HUD 还报告 hide_gui=True)。
if hide_gui:
try:
renderer.set_view(hide_gui=True)
except Exception as e:
return {"ok": False,
"why": f"无法隐藏 HUD(截图模式要求隐藏):{type(e).__name__}: {e}",
"how_to": "确认渲染后端支持 view 命令的 hide_gui;"
"或传 hide_gui=False 接受带 HUD 的图"}
cx = int(world.player.x) >> 4
cz = int(world.player.z) >> 4
try:
renderer.set_viewport(cx, cz, radius=render_distance)
except Exception as e:
notes.append(f"viewport 未设置:{type(e).__name__}")
renderer.camera(*camera)
result = renderer.screenshot(out=out, frames=frames)
info: dict[str, Any] = {
"ok": True,
"notes": notes,
"image": getattr(result, "path", None) or out,
"columns_rendered": inj["injected"],
"camera": list(camera),
"hide_gui": hide_gui,
}
try:
info["debug"] = renderer.debug()
except Exception:
pass
return info
except Exception as e:
return {"ok": False, "why": f"渲染过程失败:{type(e).__name__}: {e}",
"how_to": "看 runtime 日志;也可先用 M6 粗渲染确认地形存在"}
finally:
try:
renderer.close()
except Exception:
pass