新增 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 项全通过。
248 lines
9.3 KiB
Python
248 lines
9.3 KiB
Python
"""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
|