LLM 管意图,代码管几何
为什么不让模型直接输出坐标,而是设计一套 JSON 指令集,把"建什么"和"怎么画"拆给两个系统。
你想让 AI 造一座 Minecraft 城堡,最直接的想法是让大模型逐方块输出坐标和材质。
一栋 30×30×30 的建筑约 27,000 个体素位置。即使只列非空方块,输出仍然是数千行坐标加材质,每行长这样:
[12, 5, 8, "stone_bricks"]plaintextToken 成本是个问题,但只是表层。更深的问题是这堆坐标不可编辑、不可组合、无法增量修改、不可迁移。用户说”把屋顶换成红色”,坐标序列里没有”屋顶”这个东西,只有一片恰好构成屋顶的方块。
CubeMuse 的做法是把”建什么”交给 LLM,“怎么画”交给确定性引擎。两个系统之间用一套 JSON 指令集通信。这篇文章讲这个分离是怎么做的,以及为什么这样分。
CubeMuse 是什么#
CubeMuse 是一个浏览器端的 AI 建筑生成器,面向 Minecraft 玩家和创作者。用户用自然语言描述想要的建筑,AI 生成结构化建造方案,确定性引擎将方案渲染为体素建筑,最后导出为 Java 版(.litematic)或基岩版(.mcstructure)在游戏中使用。
核心矛盾: LLM 擅长理解自然语言意图,不擅长精确空间计算。如何让两者各干自己最擅长的事,是整个系统设计的出发点。
后文会频繁使用以下术语:
| 术语 | 定义 |
|---|---|
| Grid | 体素网格,存储每个位置的方块,是渲染和导出的唯一真相源 |
| Palette | 调色板,将 16 位索引映射到具体方块类型和状态 |
| Chunk | 16×16×16 的存储分块,Grid 只分配非空 chunk |
| Op | DSL 中的一条建造指令,如 wall、roof、door |
| Op-log | 一份完整的 DSL 程序(JSON),是产出 Grid 的”配方” |
| Blueprint | 一份建造方案 = Grid(结果) + Op-log(配方) |
为什么不让模型直出坐标#
用一个具体例子说明。一栋 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" }json3 条 op,token 量比坐标方案低一个数量级以上。
但 token 差距不是最重要的。四个系统性缺陷比成本更关键:
- 不可编辑。 想把屋顶从深色改成红色? 坐标方案需要找出所有属于屋顶的方块逐一替换。结构化方案改 palette 一行。
- 不可组合。 想复用一排窗户? 坐标方案只能复制粘贴坐标。结构化方案用
group+repeat。 - 无法增量修改。 用户说”把屋顶改成 hip 式”。坐标方案里没有”屋顶”这个概念,只有散落的方块。结构化方案替换 roof op 的
shape参数,其余 op 不受影响。 - 不可迁移。 导出
.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 对象,省去了从自由文本中提取结构的步骤,也减少了格式出错的概率。
抽象阶梯: 三层表达力#
18 个 op 按抽象程度排成三层阶梯(这是本文的分法;代码库内部 ADR-0013 按语法复杂度另有一套”词汇三级”——shape / path / blocks,两者视角不同)。抽象递减,表达力递增。LLM 应优先使用最高层,只在必要时降级。
第一层: 结构 op。 说意图,引擎算几何。
包含 wall、room、roof、staircase,以及 door、window、beam 等构件。不是独立 op 的概念通过参数实现: 地板和天花板是 room 的 floor_material / ceiling_material 参数,立柱是 beam 的 axis 取 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_left、outer_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" }
}
}jsonOp 引用角色名("material": "wall"),不引用具体方块 ID。换皮只需改 palette 一处,整栋建筑的材质联动更新。
Palette 解耦的是 op 与方块 ID,而不是模型与方块 ID。模型仍然需要认识方块: prompt 给模型列了一份方块词汇表(木料家族、石材、楼梯台阶、玻璃、16 色块、铜及其氧化变体等),模型从中选 ID 填进 palette。Palette 省掉的是”每条 op 里重复写 ID”和”换皮要改 N 处”。prompt 要求每份方案使用 3-5 种材质角色并保持对比度。
锚点定位: 让门不再悬空#
Op 设计解决了”表达什么”的问题。还有一个问题: op 怎么知道放在哪?
CubeMuse 提供三种定位模式:
- 绝对坐标
box(from/to): 闭区间,适合第一个落地的 op。 - 锚点
anchor(ref+align+offset+inset)(AI 首选): 引用前一个命名 op 的面、中心或边。 - 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_face、top_face)、角(min/max)、中心(center、center_bottom)、楼层(floor:N)或边(edge:<dir>)。这些引用关系构成一个依赖图,让整个建筑在多轮修改中保持内部一致性。锚点的合法性不是 JSON Schema 能管的(Schema 只知道 ref 是个字符串),而是由语义校验层检查: ref 必须指向前序已执行 op 的 id,part 必须是合法取值。
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 字段。引擎版本演进时(比如 roof 的 gable_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, 确定性回放 → GridplaintextGrid 不存数据库。数据库只存 Op-log(程序 JSON,含起点快照)。打开蓝图时从 Op-log 回放出 Grid。这保证了两者永远一致。确定性保证: 相同的 op 序列产出相同的 Grid。这是黄金测试(golden test)的基础。
有一个细节: 喂给 AI 的既不是 Grid 也不是完整 Op-log,而是 Structure Summary。这是一段 3-4 行的文本摘要,包含边界、材质分布 top-8、命名实体列表。原始体素数据从不直接进入 LLM 上下文。
双端导出: 版本中立方块模型#
内部统一用 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、以什么顺序调用、调用多少次?
参考资料#
- 3D Building Generation in Minecraft via Large Language Models ↗, Hu et al., 2024
- Voyager: An Open-Ended Embodied Agent with Large Language Models ↗, Wang et al., 2023
- mc-bench/orchestrator ↗, GitHub