Files
agentmc/mcfold/render_bridge.py
JianFeeeee 391d452c85 P9:Python 包装层 —— 真实存档 → 官方渲染器 → 出图
新增两模块:
  mcfold/anvil.py        存档读取(region/NBT)+ 区块编解码工具
  mcfold/render_bridge.py 桥:常驻 Java 渲染器进程,NDJSON 协议
  mcfold/render.py       对外入口:WorldRenderer(存档→画面)
  scaffold/McfoldRenderServer.java  Java 侧服务端(常驻,NDJSON)

实测(真实存档 mc-test,DataVersion 3465=1.20.1):
  injected=81  unknownBlocks=0  uniqueSpecs=111
  loadedChunks=81  chunksRendered=60  distinctColors=11585
  画面:草地/沙滩/树/水/天空/太阳/HUD(sample-real-save-render.png)

★ 分工落在实处(附录 D 的结论变成代码):
  存档 palette 是**方块名**(minecraft:oak_log[axis=y]),网络载荷要 **state id**,
  名字→id 的权威映射只有客户端注册表有 ⇒ 新增 resolve 命令,
  Java 侧用 BlockStateParser.parseForBlock 解析(实测 111 种全中,0 unknown)

踩坑(本轮新发现):
  1. level.dat 有两种布局(8字节头+zlib / 直接 gzip)—— 实测本机是后者
  2. section 索引 ≠ 绝对 Y:存档用 -4..19,载荷用 0..23(少这步 → readerIndex 越界)
  3. 注入前必须 set_viewport,否则 ClientChunkCache.inRange 丢弃区块
  4. setPos 不会更新实体区块归属,要 setPosRaw + updateViewCenter
  5. 生物群系只影响着色,用统一 plains 不影响「能渲染出世界」
  6. camera 回包要用 JsonArray,字符串会被 Python tuple() 按字符拆开

性能:启动 66s(GL+资源重载),之后每帧渲染 ~1s(含 100 帧 tick)
2026-09-25 17:33:41 +08:00

354 lines
13 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.

"""渲染桥:把 Python 侧的世界数据交给 Java 客户端渲染器出图。
设计要点
--------
两侧格式**同源**,不需要转换:
Python client.run_smoke()/read_packet() 收到的 packet_map_chunk 载荷,
其 chunkData 部分与 Java 侧 LevelChunkSection.read(FriendlyByteBuf)
期待的是同一套字节(nonEmptyBlockCount + PalettedContainer×N + Biomes)。
所以桥的协议是「把 chunk 原始字节交给 Java」,而不是「把方块逐个传过去」——
既避免重建世界的语义损失(mod 方块、NBT、biome、block entity),
又天然支持任意版本(格式由同一个 protocol.json 描述)。
通信
----
子进程 + 行分隔 JSON(NDJSON):
Java 侧 stdout 每行一个 JSON 对象;Python 写 stdin 用同样格式。
选 NDJSON 而非 stdin 二进制:可读、可日志、便于调试;
chunk 字节量不大(单区块约 10-100KB),base64 开销可接受。
"""
from __future__ import annotations
import base64
import json
import os
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
class RenderBridgeError(RuntimeError):
"""桥不可用或渲染失败。"""
@dataclass
class RenderResult:
"""一次渲染的结果。"""
image: bytes | None = None # PNG 字节
path: str | None = None
width: int = 0
height: int = 0
chunks_rendered: int = 0 # countRenderedChunks
distinct_colors: int = 0 # 判据:区分「世界」与「加载屏/空屏」
camera: tuple[float, float, float] = (0.0, 0.0, 0.0)
warnings: list[str] = field(default_factory=list)
raw: dict[str, Any] = field(default_factory=dict)
@property
def has_world_content(self) -> bool:
"""distinct_colors 是实测有效的判据:
空屏/纯色 1-2 色,加载屏 ~199(logo 抗锯齿),真实世界 >1000。"""
return self.distinct_colors > 400 and self.chunks_rendered > 0
class RenderBridge:
"""驱动 Java 渲染器子进程。
生命周期:start() → feed_chunk()×N → set_camera() → render() → stop()。
渲染器是**常驻进程**:起一次约 3-5s(GL + 资源重载),
之后每次渲染只需几十到几百毫秒。
"""
def __init__(
self,
java: str = "java",
classpath: str | list[str] | None = None,
main_class: str = "McfoldRenderServer",
game_dir: str | None = None,
assets: str | None = None,
asset_index: str | None = None,
java_args: list[str] | None = None,
startup_timeout: float = 180.0,
cwd: str | None = None,
):
self.java = java
self.main_class = main_class
self.game_dir = game_dir
self.assets = assets
self.asset_index = asset_index
self.startup_timeout = startup_timeout
self.cwd = cwd
if classpath is None:
classpath = os.environ.get("MCFOLD_RENDER_CLASSPATH")
if isinstance(classpath, (list, tuple)):
classpath = os.pathsep.join(classpath)
self.classpath = classpath
self.java_args = java_args or [
"-Xmx1200M", "-Xms512M",
# headless:不要 AWT 窗口;GL 仍走 GLFW + Xvfb
"-Djava.awt.headless=true",
]
self._proc: subprocess.Popen | None = None
self._next_id = 1
self._pending: dict[int, tuple[threading.Event, dict]] = {}
self._lock = threading.Lock()
self._reader: threading.Thread | None = None
self._stderr_tail: list[str] = []
# ---------------- 生命周期 ----------------
@property
def running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def start(self) -> "RenderBridge":
if self.running:
return self
if not self.classpath:
raise RenderBridgeError(
"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH 或传 classpath=。"
"构建:mcfold/scaffold/build.sh <store> <mcver>"
)
cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class]
if self.game_dir:
cmd += ["--gameDir", self.game_dir]
if self.assets:
cmd += ["--assets", self.assets]
if self.asset_index:
cmd += ["--assetIndex", self.asset_index]
env = dict(os.environ)
# natives 目录(lwjgl .so):构建脚本解到 MCFOLD_NATIVES_DIR
nat = env.get("MCFOLD_NATIVES_DIR")
if nat:
env["LD_LIBRARY_PATH"] = nat + os.pathsep + env.get("LD_LIBRARY_PATH", "")
try:
self._proc = subprocess.Popen(
cmd, cwd=self.cwd, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=False, bufsize=0,
)
except FileNotFoundError as e:
raise RenderBridgeError(f"无法启动 java: {e}") from e
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
threading.Thread(target=self._stderr_loop, daemon=True).start()
ready = self._await_event("ready", self.startup_timeout)
if not ready.pop("_ok", False):
self.stop()
raise RenderBridgeError(
"渲染器启动失败:" + (ready.get("error") or "超时")
+ ("\n" + "".join(self._stderr_tail[-5:]) if self._stderr_tail else "")
)
return self
def stop(self, timeout: float = 10.0) -> None:
p = self._proc
if p is None:
return
try:
if p.poll() is None:
self._send_raw({"cmd": "shutdown"})
try:
p.wait(timeout=timeout)
except subprocess.TimeoutExpired:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
finally:
self._proc = None
def __enter__(self) -> "RenderBridge":
return self.start()
def __exit__(self, *exc) -> None:
self.stop()
# ---------------- 数据注入 ----------------
def feed_chunk(self, cx: int, cz: int, chunk_data: bytes,
heightmaps_nbt: bytes | None = None) -> dict:
"""注入一个区块。
chunk_data —— packet_map_chunk 载荷里「长度前缀之后」的那段,
与 Java LevelChunkSection.read() 直接对应。
调 client 侧:decode 后的原始 payload 切片。
heightmaps_nbt —— 可选 NBT(Java 侧允许空)。
"""
return self._request({
"cmd": "chunk",
"cx": cx, "cz": cz,
"data": base64.b64encode(chunk_data).decode("ascii"),
"heightmaps": base64.b64encode(heightmaps_nbt).decode("ascii") if heightmaps_nbt else None,
})
def feed_chunks(self, chunks: list[tuple[int, int, bytes]]) -> dict:
"""批量注入(减少往返)。"""
return self._request({
"cmd": "chunks",
"items": [
{"cx": cx, "cz": cz, "data": base64.b64encode(d).decode("ascii")}
for cx, cz, d in chunks
],
})
def set_camera(self, x: float, y: float, z: float,
yaw: float = 0.0, pitch: float = 0.0) -> dict:
"""设置观察者位置与朝向(相机 = 我们想看哪里,不一定是玩家)。"""
return self._request({"cmd": "camera", "x": x, "y": y, "z": z,
"yaw": yaw, "pitch": pitch})
def set_view(self, *, camera_type: str = "THIRD_PERSON_BACK",
render_distance: int | None = None,
fov: float | None = None) -> dict:
req: dict[str, Any] = {"cmd": "view", "cameraType": camera_type}
if render_distance is not None:
req["renderDistance"] = render_distance
if fov is not None:
req["fov"] = fov
return self._request(req)
# ---------------- 渲染 ----------------
def render(self, out: str | None = None, frames: int = 60,
width: int | None = None, height: int | None = None) -> RenderResult:
"""渲若干帧并取回 PNG。
frames 不能太小:区块网格重建队列靠 tick 推进,
实测 20 帧时 countRenderedChunks 已到位,60 帧更稳。
"""
req: dict[str, Any] = {"cmd": "render", "frames": frames}
if out:
req["out"] = out
if width:
req["width"] = width
if height:
req["height"] = height
resp = self._request(req, timeout=300.0)
img_b64 = resp.pop("image", None)
res = RenderResult(
image=base64.b64decode(img_b64) if img_b64 else None,
path=resp.get("path"),
width=resp.get("width", 0),
height=resp.get("height", 0),
chunks_rendered=resp.get("chunksRendered", 0),
distinct_colors=resp.get("distinctColors", 0),
camera=tuple(resp.get("camera", (0.0, 0.0, 0.0))),
warnings=resp.get("warnings", []),
raw=resp,
)
return res
def registry_info(self) -> dict:
"""客户端方块注册表:state id ↔ 方块名(含 mod 方块的完整状态)。"""
return self._request({"cmd": "registry"})
def lookup_state(self, state_id: int) -> dict:
"""查单个 state id(返回方块名 + 完整属性,如 instrument/note/powered)。"""
return self._request({"cmd": "lookup", "stateId": state_id})
# ---------------- 内部:NDJSON 通道 ----------------
def _request(self, req: dict, timeout: float = 120.0) -> dict:
if not self.running:
raise RenderBridgeError("渲染器未运行(先 start())")
with self._lock:
rid = self._next_id
self._next_id += 1
req["id"] = rid
ev = threading.Event()
self._pending[rid] = (ev, {})
self._send_raw(req)
if not ev.wait(timeout):
self._pending.pop(rid, None)
raise RenderBridgeError(f"渲染器无响应({timeout}s):cmd={req.get('cmd')}")
_, resp = self._pending.pop(rid)
if not resp.get("_ok", True):
raise RenderBridgeError(resp.get("error", "未知错误"))
return resp
def _await_event(self, kind: str, timeout: float) -> dict:
"""等待一个主动事件(如 ready)。"""
deadline = time.time() + timeout
while time.time() < deadline:
if not self.running:
return {"_ok": False, "error": f"进程退出(code={self._proc.poll() if self._proc else '?'})"}
for rid, (ev, box) in list(self._pending.items()):
if box.get("_event") == kind and ev.is_set():
self._pending.pop(rid, None)
box["_ok"] = True
return box
time.sleep(0.05)
return {"_ok": False, "error": f"等待 {kind} 超时"}
def _send_raw(self, obj: dict) -> None:
if not self._proc or self._proc.stdin is None:
raise RenderBridgeError("stdin 不可用")
self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8"))
self._proc.stdin.flush()
def _read_loop(self) -> None:
assert self._proc and self._proc.stdout
for raw in self._proc.stdout:
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("{"):
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
rid = msg.get("id")
if rid is None and msg.get("event") == "ready":
# ready 事件:塞进 _pending 供 _await_event 取
ev = threading.Event()
box = dict(msg); box["_event"] = "ready"
self._pending[-1] = (ev, box)
ev.set()
continue
if rid in self._pending:
ev, box = self._pending[rid]
box.update(msg)
ev.set()
def _stderr_loop(self) -> None:
assert self._proc and self._proc.stderr
for raw in self._proc.stderr:
line = raw.decode("utf-8", "replace").rstrip()
self._stderr_tail.append(line)
if len(self._stderr_tail) > 200:
del self._stderr_tail[:100]
# ---------------- 便利 ----------------
@staticmethod
def discover_defaults(store: str | Path) -> dict:
"""从 store 目录推出 assets/assetIndex/gameDir 的默认值(照官方启动器布局)。"""
store = Path(store)
assets = store / "assets"
idx_dir = assets / "indexes"
asset_index = None
if idx_dir.is_dir():
names = sorted(p.stem for p in idx_dir.glob("*.json"))
asset_index = names[-1] if names else None
return {
"assets": str(assets),
"asset_index": asset_index,
"game_dir": str(store / "instances" / "default"),
}