Yuanhao Feng

返回

你想让 AI 造一座 Minecraft 城堡,最直接的想法是让大模型逐方块输出坐标和材质。

一栋 30×30×30 的建筑约 27,000 个体素位置。即使只列非空方块,输出仍然是数千行坐标加材质,每行长这样:

[12, 5, 8, "stone_bricks"]
plaintext

Token 成本是个问题,但只是表层。更深的问题是这堆坐标不可编辑、不可组合、无法增量修改、不可迁移。用户说”把屋顶换成红色”,坐标序列里没有”屋顶”这个东西,只有一片恰好构成屋顶的方块。

CubeMuse 的做法是把”建什么”交给 LLM,“怎么画”交给确定性引擎。两个系统之间用一套 JSON 指令集通信。这篇文章讲这个分离是怎么做的,以及为什么这样分。

CubeMuse 全局架构: LLM 管意图,代码管几何

CubeMuse 是什么#

CubeMuse 是一个浏览器端的 AI 建筑生成器,面向 Minecraft 玩家和创作者。用户用自然语言描述想要的建筑,AI 生成结构化建造方案,确定性引擎将方案渲染为体素建筑,最后导出为 Java 版(.litematic)或基岩版(.mcstructure)在游戏中使用。

核心矛盾: LLM 擅长理解自然语言意图,不擅长精确空间计算。如何让两者各干自己最擅长的事,是整个系统设计的出发点。

后文会频繁使用以下术语:

术语定义
Grid体素网格,存储每个位置的方块,是渲染和导出的唯一真相源
Palette调色板,将 16 位索引映射到具体方块类型和状态
Chunk16×16×16 的存储分块,Grid 只分配非空 chunk
OpDSL 中的一条建造指令,如 wall、roof、door
Op-log一份完整的 DSL 程序(JSON),是产出 Grid 的”配方”
Blueprint一份建造方案 = Grid(结果) + Op-log(配方)

为什么不让模型直出坐标#

同一栋建筑: 坐标方案 vs 结构化指令方案的 token 对比

用一个具体例子说明。一栋 8×5×6 的小屋,坐标方案的输出长这样:

[0,0,0,"stone_bricks"], [1,0,0,"stone_bricks"], [2,0,0,"stone_bricks"], ...
plaintext

约 300 个非空方块,按主流 BPE 分词器每行约 15 token,总计约 4,500 输出 token。

同一栋建筑用结构化指令:

{ "op": "room", "id": "main", "box": { "from": [0,0,0], "to": [8,4,6] }, "wall_material": "wall" }
{ "op": "roof", "anchor": { "ref": "main.top_face" }, "size": [8,4,6], "shape": "gable", "material": "roof" }
{ "op": "door", "anchor": { "ref": "main.south_face", "align": "center" }, "size": [1,2,1], "facing": "south", "material": "door" }
json

3 条 op,token 量比坐标方案低一个数量级以上。

但 token 差距不是最重要的。四个系统性缺陷比成本更关键:

  1. 不可编辑。 想把屋顶从深色改成红色? 坐标方案需要找出所有属于屋顶的方块逐一替换。结构化方案改 palette 一行。
  2. 不可组合。 想复用一排窗户? 坐标方案只能复制粘贴坐标。结构化方案用 group + repeat
  3. 无法增量修改。 用户说”把屋顶改成 hip 式”。坐标方案里没有”屋顶”这个概念,只有散落的方块。结构化方案替换 roof op 的 shape 参数,其余 op 不受影响。
  4. 不可迁移。 导出 .litematic.mcstructure 时,坐标方案丢失所有语义。结构化方案的 palette 和命名实体能映射到任意格式。

这些不只是理论推演。项目早期实测证实裸体素方案 token 成本高两个数量级且空间一致性无保证。LLM 一次调用直出完整结构的天花板同样有限(已有研究中 GPT-4 完整率仅 38%)。另一个曾考虑的方案是 Voyager 式自由代码生成。视觉天花板可能更高,但产品丢失了命名编辑手柄、调色板换肤、锚点对齐、确定性测试和 agent 的定向修订能力。CubeMuse 需要的是可编辑、可分享、可导出的蓝图,不是一次性渲染。

一套 JSON 指令集#

为什么是 JSON#

三个原因。LLM 天然能生成结构合规的 JSON,不需要维护自定义文本格式的解析器。JSON Schema 提供结构校验(字段类型、枚举值、必填项、additionalProperties: false 拒绝未知字段),在此之上还有语义校验层: 检查锚点 ref 是否指向已存在的命名实体、材质角色是否已在 palette 中声明、box.from ≤ box.to 等约束。两层校验叠加,引擎可以在执行前拦截非法 op。工具调用(tool_calls)直接返回 JSON 对象,省去了从自由文本中提取结构的步骤,也减少了格式出错的概率。

抽象阶梯: 三层表达力#

DSL op 词汇分层: 从结构意图到自由体素

18 个 op 按抽象程度排成三层阶梯(这是本文的分法;代码库内部 ADR-0013 按语法复杂度另有一套”词汇三级”——shape / path / blocks,两者视角不同)。抽象递减,表达力递增。LLM 应优先使用最高层,只在必要时降级。

第一层: 结构 op。 说意图,引擎算几何。

包含 wall、room、roof、staircase,以及 door、window、beam 等构件。不是独立 op 的概念通过参数实现: 地板和天花板是 roomfloor_material / ceiling_material 参数,立柱是 beamaxis 取 y。这层 op 描述建筑意图。模型说”这里放一面山墙屋顶”,引擎计算每个方块的朝向(东/西/南/北)、半高(上/下)和形状状态。

以 roof 为例:

{ "op": "roof", "anchor": { "ref": "main.top_face", "offset": [0,1,0] }, "size": [10,5,8], "shape": "gable", "material": "roof" }
json

模型只写了一行。引擎做的事: 确定屋脊线位置,为两道坡面分别计算朝向,填充山墙三角形(gable_fill)。出檐不是一个独立参数,而是通过把屋顶脚印开大来实现: 锚点向上偏移一格,size 每个水平轴各加 2。gable 屋顶真正在变的只有 facing(两个取值,各管一道坡面),其余状态整片统一。

hip 屋顶最能体现结构 op 的价值。它的四个角各需要不同的转角形状(outer_leftouter_right),整个屋面的朝向、形状逐块不同。如果让模型直接输出每块楼梯的 block state,错误率极高,因为这是在把最容易出错的部分交给概率模型。

第二层: 参数几何 op。 说参数,引擎算体素。

包含 shape(sphere、dome、cylinder、cone、ellipsoid、arch、pyramid 共 7 种)、path(Bresenham 折线体素化,最多 64 路点)和 fill(实心填充)。这层不再绑定建筑语义,而是通用几何原语。shape 支持 hollow 壳体,一条 op 就能造一个空心穹顶。path 用 Bresenham 算法做 3D 折线体素化,用于桅杆、树枝、缆线等线状结构。

第三层: 自由体素 op。 逃生舱。

只有一个 op: blocks,每次限 512 个方块,超出直接拒绝。直接指定每个方块的位置和材质。抽象最低、表达力最强。用于任何上两层无法表达的细节: 雕塑的五官、线条装饰、手动编辑的记录。

“只在必要时降级”不是靠自觉。prompt 将 blocks 钉死为逃生舱(“escape hatch only——always prefer structural ops”),schema 拒绝超过 512 格的调用,eval 持续监控 blocks 在输出中的占比。三条一起才能保证阶梯不塌。

后处理 op。 不产出新几何,而是在已有表面上工作。

detail 扫描当前作用域已累积的网格,自动为建筑补上勒脚、窗台、檐口等装饰细节。引擎在每份 AI 产出的程序尾部自动追加一条 detail op,prompt 据此禁止模型手动摆放这类装饰。scatter 在既有表面上随机撒落植被或碎石,内建支撑检查(只落在实体表面上,绝不覆盖、绝不悬空)。place_component 将预制组件放置到指定位置,相当于一次库调用。这三个和前面的关系是”后处理 vs 产出几何”,不是抽象高低。

设计哲学: 缺的从来不是自由度,而是阶梯的低层台阶。

力量倍增器: 组合 op#

group、repeat、mirror、rotate 四个 op 不自己产出几何,而是复用和变换其他 op 的输出。它们跨层工作:

  • group 将一组 op 封装为命名组件。子 op 在独立子网格渲染,再 stamp 到主网格。相当于函数定义。
  • repeat 沿向量平移复制一组 op。一面墙上的三扇窗户不需要写三遍。
  • mirror 沿 x/y/z 平面镜像。翻转时状态感知: 自动调整方块的 facing、half、hinge 和形状手性。楼梯镜像后朝向正确,门的铰链换边。
  • rotate 绕 Y 轴 90°/180°/270° 旋转,同样状态感知。旋转还会交换 axis 的 x/z 取值,这是 mirror 不做的。

对称建筑不需要分别描述两半。一半 + mirror = 完整建筑,还保证严格对称。

材质角色解耦#

Palette 将角色名映射到具体方块:

{
  "palette": {
    "primary": { "id": "minecraft:oak_planks" },
    "wall": { "id": "minecraft:stone_bricks" },
    "roof": { "id": "minecraft:dark_oak_stairs" },
    "accent": { "id": "minecraft:stripped_oak_log" },
    "glass": { "id": "minecraft:glass_pane" }
  }
}
json

Op 引用角色名("material": "wall"),不引用具体方块 ID。换皮只需改 palette 一处,整栋建筑的材质联动更新。

Palette 解耦的是 op 与方块 ID,而不是模型与方块 ID。模型仍然需要认识方块: prompt 给模型列了一份方块词汇表(木料家族、石材、楼梯台阶、玻璃、16 色块、铜及其氧化变体等),模型从中选 ID 填进 palette。Palette 省掉的是”每条 op 里重复写 ID”和”换皮要改 N 处”。prompt 要求每份方案使用 3-5 种材质角色并保持对比度。

锚点定位: 让门不再悬空#

Op 设计解决了”表达什么”的问题。还有一个问题: op 怎么知道放在哪?

CubeMuse 提供三种定位模式:

  1. 绝对坐标 box(from/to): 闭区间,适合第一个落地的 op。
  2. 锚点 anchor(ref + align + offset + inset)(AI 首选): 引用前一个命名 op 的面、中心或边。
  3. Along 边数组: 沿一条边以指定间距排列放置,用于窗带、柱列等重复结构。

用对比说明锚点的作用。

没有锚点时,模型需要自己算门的坐标。墙从 [0,0,0][8,4,6],门应该在 [4,0,3]。改了墙的宽度后,门还在 [4,0,3],已经不在中心了。多轮修改后坐标漂移,门悬空,窗嵌墙里。

有锚点时:

{ "op": "door", "anchor": { "ref": "main.south_face", "align": "center" }, "size": [1,2,1], "facing": "south", "material": "door" }
json

门自动贴在 main 南面中心。墙移了,门跟着移。墙加宽了,门仍在中心。

锚点是 DSL 的基本保证: 门不悬空、窗户条间距均匀、屋顶紧贴墙顶。

在底层实现上,命名实体(带 id 的 op)执行后注册自己的边界框,后续 op 通过 anchor.ref: "<name>.<part>" 引用。<part> 可以是面(south_facetop_face)、角(min/max)、中心(centercenter_bottom)、楼层(floor:N)或边(edge:<dir>)。这些引用关系构成一个依赖图,让整个建筑在多轮修改中保持内部一致性。锚点的合法性不是 JSON Schema 能管的(Schema 只知道 ref 是个字符串),而是由语义校验层检查: ref 必须指向前序已执行 op 的 id,part 必须是合法取值。

Grid 是真相,Op-log 是配方#

Blueprint 数据模型: Grid(真相源) + Op-log(配方)

Grid: 唯一真相源#

渲染读它,导出读它,统计读它。

  • 16×16×16 chunk 稀疏存储,只分配非空 chunk
  • 每个格位存 16 位 palette 索引,最多 65,535 种不同方块
  • 索引 0 = air
  • 存储结构同为 palette + 索引数组,与 .litematic / .schem / .mcstructure 三种格式同构;导入导出时适配器按各格式要求做索引重排和字节序转换

Op-log: 配方,不是结果#

Op-log 是一份 DSL 程序的 JSON 文本。从起点状态执行一遍,确定性地产出完整 Grid。起点通常是空网格,但导入的现有建筑会编码为起点快照(边界框 + palette + RLE 压缩体素)存入程序的 base 字段,后续 op 在此基础上追加。导入件与生成件在编辑、撤销、AI 修订、导出上走同一条路径。

程序自带 dsl_version 字段。引擎版本演进时(比如 roofgable_fill 缺省值从 false 改为 true),迁移层给存量程序显式补上旧缺省值,确保老图纸逐格不变、新程序享受新缺省。“配方”能跨版本重放,是 Op-log 作为持久表示的前提。

为什么两个都需要#

只有 Op-log 没有 Grid: 每次渲染和导出都要从头回放全部 op。

只有 Grid 没有 Op-log: AI 修改时只能喂回原始体素,token 直接爆炸。版本历史和建造过程全部丢失。也无法做 replace_group 这类语义级的定向修改。

所以 Blueprint = Grid + Op-log。两者各管一面:

Blueprint
├── Grid           ← 渲染 / 导出读这里
│   ├── Palette    ← 索引 → 方块类型+状态
│   └── Chunks[]   ← 16³ 稀疏分块
└── Op-log         ← AI 理解 / 版本对比读这里
    └── DslProgram ← JSON, 确定性回放 → Grid
plaintext

Grid 不存数据库。数据库只存 Op-log(程序 JSON,含起点快照)。打开蓝图时从 Op-log 回放出 Grid。这保证了两者永远一致。确定性保证: 相同的 op 序列产出相同的 Grid。这是黄金测试(golden test)的基础。

有一个细节: 喂给 AI 的既不是 Grid 也不是完整 Op-log,而是 Structure Summary。这是一段 3-4 行的文本摘要,包含边界、材质分布 top-8、命名实体列表。原始体素数据从不直接进入 LLM 上下文。

双端导出: 版本中立方块模型#

版本中立方块到 Java 和基岩双端的导出路径

内部统一用 Java 风格命名加方块状态(block states)作为中立表示,称为 NeutralBlock。这不是自创的命名空间,而是复用 Java 版的体系,因为 Java 版的方块名覆盖最全、社区工具链最成熟。

在导入/导出边界,适配器做格式转换:

  • Java 端: .litematic(Litematica)导入导出 / .schem(Sponge v1-v3)仅导入
  • 基岩端: .mcstructure(Structure Block)

跨版本差异在导出时检测,生成替换报告。例如 Java 的 quartz_pillar 在基岩端对应 quartz_block[chisel_type=lines],标为 renamed;楼梯的 Java shape 状态(转角形态)在基岩端被丢弃(基岩自动计算拐角);词汇表外的方块原样保留名称并标为 unsupported。映射表只覆盖 DSL 词汇表实际产出的方块,不嵌入完整的万行跨版本数据集。

结语#

回到标题。这套系统拆出了四条清晰的职责线:

  • DSL 让模型用意图说话,引擎把意图翻译成几何。模型不碰 block state。
  • 锚点让模型引用命名实体,而不是计算坐标。结构内部一致性由依赖图保证。
  • Palette 让模型选角色,而不是在每条 op 里重复方块 ID。换皮是一行的事。
  • 双表示让系统同时拥有语义和像素: Op-log 给 AI 和人读,Grid 给渲染器和导出器读。

这套管线是 CubeMuse 所有后续设计的基底。有了它,下一步的问题是: 谁来决定调用哪些 op、以什么顺序调用、调用多少次?

参考资料#

LLM 管意图,代码管几何
https://me.yuanhaofeng.com/blog/cubemuse-build-dsl
作者 Yuanhao Feng
发布于 2026年9月9日