Files
agentmc/mcfold/render.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

366 lines
14 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.

"""把世界数据渲染成画面 —— mcfold 的对外渲染入口。
分工(与设计文档附录 D 一致):
本模块(Python) : 世界数据 —— 区块、方块、光照、相机
客户端 jar(Java): 方块名与 state id 的权威映射 + 画面
为什么 state id 必须由渲染器侧解析:
存档 palette 里是**方块名**(`minecraft:oak_log[axis=y]`),
而网络载荷要 **state id**。名字→id 的映射随版本/加载器/mod 变化,
只有客户端自带的注册表是权威的(1.20.1 不由网络下发,附录 D)。
用法:
from mcfold.render import WorldRenderer
with WorldRenderer.open(instance) as wr:
wr.load_save("/home/mc-test/mc-test", center=(-1, -1), radius=3)
wr.camera(x=8, y=80, z=8, yaw=135, pitch=35)
img = wr.screenshot("/tmp/out.png")
"""
from __future__ import annotations
import base64
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from . import anvil
from .render_bridge import RenderBridge, RenderBridgeError, RenderResult
# 存档区块在网络载荷里的 section 数(1.20.1 worldHeight 384 / 16 = 24)
SECTION_COUNT = 24
SECTION_VOLUME = 16 * 16 * 16
BIOME_VOLUME = 4 * 4 * 4
# ★ 最小 section 的 Y:1.20 起 worldHeight 384、minBuildHeight -64 ⇒ minSection = -4。
# 网络载荷里的 section 索引是「从下往上数」(0..23),而存档 NBT 用的是绝对 Y(-4..19)。
MIN_SECTION_Y = -4
# 位打包:把 palette 索引列表压进 long(值不跨 long 边界)
def _pack(values: list[int], bits: int) -> list[int]:
if bits <= 0:
return []
vpl = 64 // bits
mask = (1 << bits) - 1
out, cur, slot = [], 0, 0
for v in values:
cur |= (v & mask) << (slot * bits)
slot += 1
if slot == vpl:
out.append(cur); cur, slot = 0, 0
if slot:
out.append(cur)
return out
def _varint(v: int) -> bytes:
out = bytearray()
while True:
b = v & 0x7F; v >>= 7
if v: out.append(b | 0x80)
else: out.append(b); break
return bytes(out)
def _long_array(longs: list[int]) -> bytes:
out = _varint(len(longs))
for l in longs:
out += (l & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "big")
return out
def _encode_paletted(state_ids: list[int], volume: int, min_bits: int = 4,
global_bits: int = 15, max_local_bits: int = 8) -> bytes:
"""编码一个 PalettedContainer 载荷(与 Java PalettedContainer.read 对齐)。"""
uniq = sorted(set(state_ids))
if len(uniq) == 1:
return bytes([0]) + _varint(uniq[0]) + _varint(0)
need = (len(uniq) - 1).bit_length()
if need > max_local_bits:
# 全局调色板:位宽直接放 state id
return bytes([global_bits]) + _long_array(_pack(state_ids, global_bits))
bits = max(min_bits, need)
idx = {v: i for i, v in enumerate(uniq)}
out = bytes([bits]) + _varint(len(uniq))
for v in uniq:
out += _varint(v)
out += _long_array(_pack([idx[v] for v in state_ids], bits))
return out
def _decode_storage(longs: Iterable[int], volume: int, bits: int) -> list[int]:
"""int[] 位数组 → 索引列表(不跨 long)。"""
longs = list(longs)
if bits == 0 or not longs:
return [0] * volume
vpl = 64 // bits
mask = (1 << bits) - 1
out = []
for i in range(volume):
li = i // vpl
if li >= len(longs):
out.append(0); continue
off = (i - li * vpl) * bits
out.append((longs[li] >> off) & mask)
return out
@dataclass
class SaveChunk:
"""存档里一个区块的、已解析好的方块与生物群系。"""
cx: int
cz: int
status: str = ""
sections: dict[int, dict] = field(default_factory=dict) # y_idx -> {"palette": [名字], "states": [4096 名字], "biomes": [64 名字]}
palette_specs: list[str] = field(default_factory=list) # 全部去重后的方块名(+属性)规格
def _props_to_spec(name: str, props: dict | None) -> str:
"""{'Name':'minecraft:oak_log','Properties':{'axis':'y'}} → 'minecraft:oak_log[axis=y]'"""
if not props:
return name
parts = ",".join(f"{k}={v}" for k, v in sorted(props.items()))
return f"{name}[{parts}]"
def parse_save_chunk(cx: int, cz: int, nbt: dict) -> SaveChunk:
"""存档区块 NBT → SaveChunk(方块用**名字规格**表示,state id 留给渲染器解析)。"""
ch = SaveChunk(cx=cx, cz=cz, status=str(nbt.get("Status", "")))
specs_seen: set[str] = set()
by_y: dict[int, dict] = {}
for s in nbt.get("sections", []) or []:
sy = s.get("Y")
if sy is None:
continue
bs = s.get("block_states")
if not isinstance(bs, dict):
continue
palette = bs.get("palette") or []
spec_list = [
_props_to_spec(str(p.get("Name", "minecraft:air")), p.get("Properties"))
for p in palette
]
for sp in spec_list:
specs_seen.add(sp)
# 存档 data 是「按 long 打包的调色板索引」(无 varint 前缀)
storage = bs.get("data") or []
n = len(spec_list)
if n <= 1:
states = [spec_list[0] if spec_list else "minecraft:air"] * SECTION_VOLUME
else:
bits = max(4, (n - 1).bit_length())
idxs = _decode_storage(storage, SECTION_VOLUME, bits)
states = [spec_list[i] if i < n else "minecraft:air" for i in idxs]
# 生物群系:同样结构,4x4x4
bio = s.get("biomes") or {}
bio_pal = bio.get("palette") or []
bio_specs = [
(p if isinstance(p, str) else str(p.get("Name", "minecraft:plains")))
for p in bio_pal
]
if not bio_specs:
biomes = ["minecraft:plains"] * BIOME_VOLUME
elif len(bio_specs) == 1:
biomes = [bio_specs[0]] * BIOME_VOLUME
else:
bb = max(1, (len(bio_specs) - 1).bit_length())
bidx = _decode_storage(bio.get("data") or [], BIOME_VOLUME, bb)
biomes = [bio_specs[i] if i < len(bio_specs) else "minecraft:plains" for i in bidx]
by_y[int(sy)] = {"palette": spec_list, "states": states, "biomes": biomes}
ch.sections = by_y
ch.palette_specs = sorted(specs_seen)
return ch
class WorldRenderer:
"""世界 → 画面。持有一个常驻 Java 渲染器进程。"""
def __init__(self, bridge: RenderBridge, instance: Any | None = None):
self.bridge = bridge
self.instance = instance
self._spec_cache: dict[str, int] = {}
self._loaded: set[tuple[int, int]] = set()
self.chunks: dict[tuple[int, int], SaveChunk] = {}
# ---------------- 构造 ----------------
@classmethod
def open(cls, instance: Any = None, *, store: str | None = None,
classpath: str | None = None, start: bool = True,
java: str = "java") -> "WorldRenderer":
"""从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。
classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH;
可先跑 mcfold/scaffold/build.sh 生成。
"""
game_dir = assets = asset_index = None
if instance is not None:
root = getattr(instance, "content_root", None) or getattr(instance, "path", None)
if root:
root = Path(root)
assets = str(root / "assets")
idx = root / "assets" / "indexes"
if idx.is_dir():
names = sorted(p.stem for p in idx.glob("*.json"))
asset_index = names[-1] if names else None
game_dir = str(getattr(instance, "path", root) if root else "")
if store:
store_p = Path(store)
assets = str(store_p / "assets")
names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json"))
asset_index = names[-1] if names else None
game_dir = game_dir or str(store_p / "instances" / "default")
bridge = RenderBridge(
java=java, classpath=classpath,
game_dir=game_dir, assets=assets, asset_index=asset_index,
)
wr = cls(bridge, instance)
if start:
bridge.start()
return wr
def close(self) -> None:
self.bridge.stop()
def __enter__(self) -> "WorldRenderer":
return self
def __exit__(self, *exc) -> None:
self.close()
# ---------------- 方块名 → state id ----------------
def resolve_specs(self, specs: list[str]) -> dict[str, int]:
"""批量把方块名规格解析成 state id(渲染器侧权威注册表)。"""
todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache]
if todo:
resp = self.bridge._request({"cmd": "resolve", "blocks": todo}, timeout=120.0)
ids = resp.get("ids", [])
for spec, sid in zip(todo, ids):
self._spec_cache[spec] = int(sid)
return {s: self._spec_cache[s] for s in specs}
# ---------------- 装载 ----------------
def load_save(self, save_dir: str | Path, *, center: tuple[int, int] | None = None,
radius: int = 3, dim: str | None = None,
only_status: str = "minecraft:full") -> dict:
"""从存档装载区块(零网络、零登录)。
center/radius 控制范围(区块坐标),避免一次喂太大。
"""
save_dir = Path(save_dir)
level = anvil.read_level_dat(save_dir)
data = level.get("Data", {})
spawn = data.get("SpawnX"), data.get("SpawnY"), data.get("SpawnZ")
if center is None:
center = ((spawn[0] or 0) >> 4, (spawn[2] or 0) >> 4)
regs: dict[tuple[int, int], anvil.RegionFile] = {}
# ★ 先设视野中心:ClientChunkCache 只保留视野内的区块(实测否则注入被丢弃)
self.set_viewport(center[0], center[1], radius=max(radius + 2, 8))
parsed: list[SaveChunk] = []
skipped = 0
for cx in range(center[0] - radius, center[0] + radius + 1):
for cz in range(center[1] - radius, center[1] + radius + 1):
rx, rz = cx >> 5, cz >> 5
key = (rx, rz)
if key not in regs:
p = anvil.region_path(save_dir, dim, rx, rz)
if not p.is_file():
skipped += 1; continue
regs[key] = anvil.RegionFile(p)
raw = regs[key].chunk_nbt(cx, cz)
if not raw:
skipped += 1; continue
nbt = anvil.nbt_read(raw)
if only_status and str(nbt.get("Status", "")) != only_status:
skipped += 1; continue
parsed.append(parse_save_chunk(cx, cz, nbt))
return self.load_chunks(parsed, meta={
"level_name": data.get("LevelName"),
"data_version": data.get("DataVersion"),
"spawn": spawn,
"center": center,
"skipped": skipped,
})
def load_chunks(self, chunks: list[SaveChunk], meta: dict | None = None) -> dict:
"""把已解析的区块编码并注入渲染器。"""
all_specs: list[str] = []
for c in chunks:
all_specs.extend(c.palette_specs)
id_map = self.resolve_specs(all_specs)
unknown = sum(1 for v in id_map.values() if v < 0)
items: list[tuple[int, int, bytes]] = []
for c in chunks:
payload = self._encode_chunk(c, id_map)
items.append((c.cx, c.cz, payload))
self.chunks[(c.cx, c.cz)] = c
resp = self.bridge.feed_chunks(items) if items else {"injected": 0}
self._loaded.update((c.cx, c.cz) for c in chunks)
out = {"injected": len(items), "unknownBlocks": unknown,
"uniqueSpecs": len(id_map)}
if meta:
out.update(meta)
return out
def _encode_chunk(self, c: SaveChunk, id_map: dict[str, int]) -> bytes:
"""SaveChunk → packet_map_chunk 的 chunkData 载荷。"""
out = bytearray()
for sy in range(SECTION_COUNT):
# ★ 索引 0..23 ↔ 绝对 Y (-4..19):存档用绝对 Y,载荷用相对索引
sec = c.sections.get(sy + MIN_SECTION_Y)
if sec is None:
# 空 section:空气
out += _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME)
else:
ids = [max(0, id_map.get(s, -1)) for s in sec["states"]]
non_empty = sum(1 for v in ids if v != 0)
out += (non_empty & 0xFFFF).to_bytes(2, "big")
out += _encode_paletted(ids, SECTION_VOLUME)
# biome section
bios = sec["biomes"] if sec else ["minecraft:plains"] * BIOME_VOLUME
bio_pal = sorted(set(bios))
# 生物群系用全局调色板 id 需要 registry —— 简化:统一用 plains 的 id 1
# (生物群系只影响草/树叶着色,不影响「能不能渲染出世界」)
out += bytes([0]) + _varint(1) + _varint(0)
return bytes(out)
# ---------------- 视角与截图 ----------------
def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict:
"""设定视野中心 —— 必须在注入前设好,否则区块会被 ClientChunkCache 丢弃。"""
req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz}
if radius is not None:
req["radius"] = radius
return self.bridge._request(req)
def debug(self) -> dict:
"""诊断:玩家坐标/区块/网格编译/视野中心的实际状态。"""
return self.bridge._request({"cmd": "debug"})
def camera(self, x: float, y: float, z: float,
yaw: float = 0.0, pitch: float = 0.0) -> None:
self.bridge.set_camera(x, y, z, yaw, pitch)
def camera_above(self, cx: float = 0.0, cz: float = 0.0, height: float = 90.0,
yaw: float = 0.0, pitch: float = 70.0) -> None:
"""俯视整片区域(默认视角)。"""
self.bridge.set_camera(cx, height, cz, yaw, pitch)
def screenshot(self, out: str | None = None, frames: int = 60) -> RenderResult:
return self.bridge.render(out=out, frames=frames)