
上个月接了个活,给一个仓储题材的小游戏做场景道具。需求单摊开一看:六十多个木箱、托盘、货架,尺寸各不相同,还得能直接丢进 Unity 和 three.js 里跑。按老办法,这种量得排两周,中间还得祈祷美术不请假。
我盯着那张单子看了十分钟,然后干了件当时觉得有点冒险的事——把 Blender 关了,打开终端,敲了一行 codex。
三天后交付。不是因为我手速快,是因为我基本没建模。
这篇文章就把那三天干的事原原本本写下来:怎么搭环境、怎么让 AI 真的"看见"渲染结果、脚本长什么样、踩了哪些坑、以及这套东西到底能干到哪一步。代码都是能直接跑的,不是伪代码。
先把名词说清楚,不然容易聊岔。
Codex 在这里指的是 OpenAI 那个跑在终端里的编码 agent(不是早年那个补全模型)。它能读你仓库里的文件、改文件、执行 shell 命令、看命令输出,还能读图片。这几点很关键——它不是"聊天窗口里吐一段代码让你自己复制"的那种,它是能自己动手、自己验证的。
Blender 表面上是个 GUI 软件,但底下全是 Python。bpy 这个模块能干 UI 能干的几乎所有事,而且支持 blender -b 无头模式,也就是不开窗口、纯命令行跑。
把这两句话拼起来,事情就变得有意思了:
Blender 本质上是一个"可以被脚本驱动的几何计算器 + 渲染器"。Codex 负责写和改脚本,Blender 负责执行和出图。
中间那层胶水是 Python,闭环靠的是一张渲染出来的 PNG。
为什么这事能成?因为建模里有一大类工作,压根不是审美问题,是参数问题。货架的横梁间距、木箱的倒角半径、托盘的板条数量、管道的分段数——这些全是数字。数字就该写成变量,变量就该交给机器去排列组合。
我以前也写过 bpy 脚本,写是能写,但改起来很痛苦:改一个参数,跑一遍,等渲染,看结果,发现不对,再改。一晚上能迭代十几次就算高效了。现在这个"改—跑—看"的循环被 agent 接过去了,我只需要在它跑偏的时候拽一把。
不用装什么插件,也不用配 API 中转,官方 CLI 就够。
# 1. Blender 4.2 LTS 或 4.5,装完把 blender 加进 PATH
blender --version
# 2. Node 18 以上
node -v
# 3. 装 Codex CLI
npm i -g @openai/codex
codex login然后建一个空目录,git init 一下。这一步别省,agent 改嗨了容易把能跑的文件改成不能跑的,有 git 你随时能 git checkout . 退回去。
验证 Blender 无头模式能不能用,跑这行:
blender -b --factory-startup --python-expr "import bpy; print('OK', bpy.app.version_string)"看到版本号就通了。
这里有个参数值得单独说:--factory-startup。它的作用是跳过你本机的用户配置和第三方插件,用一个干净的出厂状态启动。团队协作的时候这个参数几乎是必须的——每个人的 Blender 设置都不一样,你不加它,本地跑得好好的脚本,换台机器或者上 CI 就炸,而且报错信息往往驴唇不对马嘴。
先上图,后面所有内容都是围着这张图转的。

图里有两个判断节点,一个是"退出码",一个是"看着达标吗"。第一个好理解,程序崩了就把报错喂回去。第二个才是这套方法的核心,也是最多人做错的地方。
Codex 看不见你的视口。 这是最大的认知差。你在屏幕前一眼就能看出"这个箱子倒角太硬",但对 agent 来说,那个 .blend 文件是一坨二进制,它读不了。
所以你必须把"视觉"翻译成"文件"。我用了三样东西:
这三样凑齐,agent 才算长出了眼睛。
别一上来就搞批量,先让一个立方体转起来。这个最小脚本我建议你亲手跑一遍,很多坑只有跑过才有体感。
# minimal.py —— 最小可用示例
import bpy
from mathutils import Vector
def clear():
bpy.ops.wm.read_factory_settings(use_empty=True)
def make_material(name, rgb, rough=0.65):
m = bpy.data.materials.new(name)
m.use_nodes = True
bsdf = m.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (*rgb, 1.0)
bsdf.inputs["Roughness"].default_value = rough
return m
def make_box(size=(1.0, 0.6, 0.4), bevel=0.012):
bpy.ops.mesh.primitive_cube_add(size=1) # 注意 size=1
obj = bpy.context.active_object
obj.scale = (size[0], size[1], size[2])
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
md = obj.modifiers.new("bevel", 'BEVEL')
md.width = bevel
md.segments = 2
md.limit_method = 'ANGLE'
return obj
clear()
obj = make_box()
obj.data.materials.append(make_material("wood", (0.42, 0.26, 0.14)))
# 导出 GLB
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(
filepath="//demo.glb",
export_format='GLB',
use_selection=True,
export_apply=True,
)
print("done")跑:
blender -b --factory-startup -P minimal.py跑通之后,有三个地方值得停下来想一想,全是真金白银换来的经验:
第一,primitive_cube_add 默认建的是 2 米见方的立方体。 size 参数默认值是 2,不是 1。我第一次写的时候按 1 算,结果所有道具大了整整一倍,进引擎之后跟角色一样高,看着特别荒诞。上面代码里显式写了 size=1,就是为了躲这个。
第二,缩放之后必须 transform_apply。 不 apply 的话,倒角修改器吃到的宽度会被非均匀缩放拉伸,横向的边和纵向的边倒角宽度不一样,出来是歪的。这个 bug 特别隐蔽,因为你在视口里不一定看得出来,导出之后才会发现边缘发虚。
第三,Blender 4.x 改了材质节点的输入名。 网上大量 3.x 的教程写 bsdf.inputs["Specular"],在 4.x 里直接 KeyError——现在叫 Specular IOR Level,Transmission 改成了 Transmission Weight,自发光拆成了 Emission Color 和 Emission Strength。抄代码之前先 print(bsdf.inputs.keys()) 看一眼,能省一小时。
这一步是整套流程的灵魂。我用一张时序图说明一次完整的迭代。

第三段是关键。怎么让它读图?Codex CLI 支持 -i 传图片:
codex -i out/thumbs/pallet_A.png "这个托盘的板条缝隙是不是太大了?按 12mm 的标准重新算一遍"它会把图当输入看。这里有个小技巧:缩略图一定要固定机位、固定焦距、固定光照。因为 agent 判断"改前和改后有什么不同",靠的是对比。你每次换角度换灯光,它就被干扰了,容易得出"变化很大"这种废话结论。我在脚本里把相机方向写死成 (1.0, -1.4, 0.9) 归一化之后的向量,就是为了这个。
批量场景用 codex exec,非交互模式,可以直接塞进 shell 脚本或者 CI:
codex exec "读 out/build.log 和 out/manifest.json。
托盘 slats=5 的那几个,板条之间的缝太宽了,把 gap*0.8 改成自适应:
缝隙固定 12mm,板条宽度按剩余空间均分。
改完执行 ./run.sh,然后对比 out/thumbs 下的 PNG 有没有变化。
不要动 crate 和 shelf 的生成逻辑。"注意最后那句"不要动别的"。这是必须写的。agent 有个通病叫过度热情:你让它修个缝隙,它顺手给你加上 UV 展开、LOD、自动命名规范,最后 diff 三百行,你根本没法 review。
治这个病的药是 AGENTS.md,放在仓库根目录,Codex 每次启动都会读。相当于给它一份团队规范:
# AGENTS.md
## 硬性约束
- 所有脚本必须能在 `blender -b --factory-startup` 下跑通,禁止依赖 UI 上下文
- 不要使用 bpy.context.screen / bpy.context.area,无头模式下是 None
- 单位统一为米,Z 轴向上,导出时转 Y-up
- 只修改被明确要求的函数,不要顺手重构、不要加未被要求的功能
## 验证流程
每次改动后必须:
1. `bash run.sh`,确认退出码为 0
2. 检查 out/manifest.json 里的面数,单个资产不得超过 3000 三角面
3. 至少打开一张 out/thumbs/*.png 确认外形
## 命名
- 资产名:`{类别}_{变体}_{宽}x{深}x{高}`,例如 `crate_A_60x40x40`
- 材质:`wood_{hex}` / `metal_{hex}`这份文件写得好不好,直接决定 agent 第几次能改对。我的体感是:写清楚了,通常两三轮收敛;不写,它能在一个坑里来回横跳五次。
好,铺垫够了,上真东西。
props-lab/
├── AGENTS.md
├── props.csv
├── build_props.py
├── run.sh
└── out/
├── glb/
├── thumbs/
├── manifest.json
└── build.logname,kind,w,d,h,n,color
crate_A_60x40x40,crate,0.60,0.40,0.40,0,#8a6a4a
crate_B_80x50x50,crate,0.80,0.50,0.50,0,#9c7b55
crate_C_40x40x30,crate,0.40,0.40,0.30,0,#7a5c3e
pallet_A_120x100,pallet,1.20,1.00,0.14,5,#a8845c
pallet_B_100x80,pallet,1.00,0.80,0.14,5,#b08d63
shelf_A_180x60,shelf,1.80,0.60,2.00,4,#6b4f36
shelf_B_120x45,shelf,1.20,0.45,1.60,3,#5e452fn 这一列在不同类型里含义不同:托盘是板条数,货架是层数,木箱暂时不用。这种"复用一列"的做法在配置表里很常见,别嫌脏,比开三张表省事。
"""build_props.py —— 仓储道具批量生成器
用法:
blender -b --factory-startup -P build_props.py -- --csv props.csv --out out
"""
import argparse
import csv
import json
import os
import sys
import bpy
from mathutils import Vector
# ---------------- 基础设施 ----------------
def reset():
"""每个资产都从空场景开始,避免互相污染。"""
bpy.ops.wm.read_factory_settings(use_empty=True)
def hex2rgb(h):
"""sRGB hex -> 线性 RGB。
Principled BSDF 的 Base Color 吃的是线性值,
直接把 sRGB 的 0-1 值塞进去,出来会偏亮偏灰。
"""
h = h.lstrip("#")
srgb = [int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)]
def lin(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return tuple(lin(c) for c in srgb)
def mat(name, rgb, rough=0.68, metal=0.0):
m = bpy.data.materials.get(name)
if m:
return m
m = bpy.data.materials.new(name)
m.use_nodes = True
b = m.node_tree.nodes["Principled BSDF"]
b.inputs["Base Color"].default_value = (*rgb, 1.0)
b.inputs["Roughness"].default_value = rough
b.inputs["Metallic"].default_value = metal
return m
def box(name, size, loc=(0, 0, 0), material=None, bevel=0.008):
bpy.ops.mesh.primitive_cube_add(size=1, location=loc)
o = bpy.context.active_object
o.name = name
o.scale = size
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
if bevel > 0:
md = o.modifiers.new("bevel", 'BEVEL')
md.width = bevel
md.segments = 2
md.limit_method = 'ANGLE'
if material:
o.data.materials.append(material)
return o
def join(name, objs):
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = name
return obj
def origin_to_base(obj):
"""把原点挪到世界原点(地面中心),进引擎好摆。"""
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
# ---------------- 三种资产 ----------------
def build_crate(name, w, d, h, n, color):
m = mat(f"wood_{color}", hex2rgb(color))
parts = [box(f"{name}_body", (w, d, h), (0, 0, h / 2), m)]
t = 0.028
for sx in (-1, 1):
for sy in (-1, 1):
parts.append(box(
f"{name}_edge_{sx}_{sy}", (t, t, h * 0.96),
(sx * (w / 2 - t / 2), sy * (d / 2 - t / 2), h / 2), m, 0.003))
return join(name, parts)
def build_pallet(name, w, d, h, n, color):
m = mat(f"wood_{color}", hex2rgb(color))
parts = []
bh = 0.022 # 面板厚
beam_h = h - bh # 纵梁高
for i in range(3):
x = -w / 2 + 0.05 + i * (w - 0.10) / 2
parts.append(box(f"{name}_beam{i}", (0.06, d, beam_h),
(x, 0, beam_h / 2), m, 0.004))
# 板条:缝隙固定 12mm,宽度按剩余空间均分
gap = 0.012
slat_w = (w - gap * (n + 1)) / n
for i in range(n):
x = -w / 2 + gap * (i + 1) + slat_w * (i + 0.5)
parts.append(box(f"{name}_slat{i}", (slat_w, d, bh),
(x, 0, beam_h + bh / 2), m, 0.004))
return join(name, parts)
def build_shelf(name, w, d, h, n, color):
m = mat(f"wood_{color}", hex2rgb(color))
parts = []
post = 0.04
levels = max(2, n)
for sx in (-1, 1):
for sy in (-1, 1):
parts.append(box(
f"{name}_post_{sx}_{sy}", (post, post, h),
(sx * (w / 2 - post / 2), sy * (d / 2 - post / 2), h / 2),
m, 0.003))
for i in range(levels):
z = (i + 0.5) * h / levels
parts.append(box(f"{name}_deck{i}", (w - post, d - post, 0.02),
(0, 0, z), m, 0.003))
return join(name, parts)
BUILDERS = {"crate": build_crate, "pallet": build_pallet, "shelf": build_shelf}
# ---------------- 摄影棚 ----------------
def setup_studio():
sc = bpy.context.scene
sc.render.engine = 'CYCLES'
sc.cycles.device = 'CPU'
sc.cycles.samples = 24
sc.render.resolution_x = 512
sc.render.resolution_y = 512
sc.render.film_transparent = True
sc.render.image_settings.file_format = 'PNG'
def frame_camera(objs):
pts = []
for o in objs:
for c in o.bound_box:
pts.append(o.matrix_world @ Vector(c))
mn = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
mx = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
center = (mn + mx) / 2
radius = max((mx - mn).length / 2, 0.1)
cd = bpy.data.cameras.new("cam")
cd.lens = 50
cam = bpy.data.objects.new("cam", cd)
bpy.context.collection.objects.link(cam)
# 固定机位,保证改前改后可对比
direction = Vector((1.0, -1.4, 0.9)).normalized()
cam.location = center + direction * radius * 3.6
cam.rotation_euler = (center - cam.location).to_track_quat('-Z', 'Y').to_euler()
bpy.context.scene.camera = cam
return center, radius
def add_lights(center, radius):
rig = [((1.2, -1.6, 1.4), 220), ((-1.6, -0.8, 0.6), 90), ((0.2, 1.8, 1.2), 60)]
for i, (dirv, energy) in enumerate(rig):
ld = bpy.data.lights.new(f"L{i}", 'AREA')
ld.energy = energy * max(radius, 0.4) ** 2
ld.size = 2.0 * max(radius, 0.4)
lo = bpy.data.objects.new(f"L{i}", ld)
v = Vector(dirv).normalized()
lo.location = center + v * radius * 3.0
lo.rotation_euler = (center - lo.location).to_track_quat('-Z', 'Y').to_euler()
bpy.context.collection.objects.link(lo)
def render_png(path):
bpy.context.scene.render.filepath = path
bpy.ops.render.render(write_still=True)
def export_glb(obj, path):
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf(
filepath=path,
export_format='GLB',
use_selection=True,
export_apply=True, # 关键:把修改器烘进网格
export_yup=True,
)
# ---------------- 主流程 ----------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("--csv", required=True)
ap.add_argument("--out", default="out")
args = ap.parse_args(argv)
os.makedirs(f"{args.out}/glb", exist_ok=True)
os.makedirs(f"{args.out}/thumbs", exist_ok=True)
with open(args.csv, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
manifest = []
for row in rows:
reset()
kind = row["kind"]
builder = BUILDERS[kind]
obj = builder(row["name"], float(row["w"]), float(row["d"]),
float(row["h"]), int(row["n"]), row["color"])
origin_to_base(obj)
setup_studio()
c, r = frame_camera([obj])
add_lights(c, r)
render_png(f"{args.out}/thumbs/{row['name']}.png")
export_glb(obj, f"{args.out}/glb/{row['name']}.glb")
tris = sum(len(p.vertices) - 2 for p in obj.data.polygons)
manifest.append({
"name": row["name"],
"kind": kind,
"size": [float(row["w"]), float(row["d"]), float(row["h"])],
"tris": tris,
"glb": f"glb/{row['name']}.glb",
"thumb": f"thumbs/{row['name']}.png",
})
print(f"[ok] {row['name']:24s} tris={tris}")
with open(f"{args.out}/manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
print(f"[done] {len(manifest)} assets")
main()#!/usr/bin/env bash
set -euo pipefail
blender -b --factory-startup -P build_props.py -- \
--csv props.csv --out out 2>&1 | tee out/build.logset -euo pipefail 不是凑数的。Codex 判断成败主要看退出码,tee 会把管道的退出码吃掉,加了 pipefail 才能把 Blender 的失败传出来。我第一次跑的时候没加,脚本崩了 Codex 还以为成功了,然后一本正经地分析一份空 manifest,那种荒谬感很难形容。
chmod +x run.sh
./run.sh7 个资产,CPU 渲染,大概 40 秒。日志长这样:
[ok] crate_A_60x40x40 tris=124
[ok] crate_B_80x50x50 tris=124
[ok] pallet_A_120x100 tris=880
[ok] shelf_A_180x60 tris=1320
[done] 7 assetsmanifest 是给引擎和前端看的,GLB 直接用:
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
const loader = new GLTFLoader();
const manifest = await fetch('/out/manifest.json').then(r => r.json());
for (const item of manifest) {
loader.load(`/out/${item.glb}`, (gltf) => {
const mesh = gltf.scene;
mesh.position.set(Math.random() * 10, 0, Math.random() * 10);
scene.add(mesh);
});
}因为生成时原点已经挪到了地面中心,摆位的时候 y = 0 就是贴地,不用一个个去量包围盒。这种"在导出阶段把麻烦解决掉"的小设计,能省掉后面一整轮的返工。
第一天上午搭环境、写第一版脚本,跑通三个生成器。下午开始调外观,改了四轮:倒角太硬、托盘板条缝隙不均、货架层板位置偏、木色发灰(就是 sRGB 那个坑)。
第二天上午批量跑 60 个变体,发现有几个尺寸超界的会穿模,加了个尺寸合法性校验。下午 Codex 主动提了个建议——把 manifest 里加上面数统计,方便在引擎里做 LOD 分组。这个我确实没想到,算是意外收获。
第三天做集成和文档,把 AGENTS.md 补完,顺手写了段 CI 脚本,每次 push 自动跑一遍生成器,面数超 3000 就挂。
真正的建模时间大概是零。绝大部分时间花在"说清楚要什么"和"看它做对了没有"上。
坑一:bpy.ops 在无头模式下会莫名失败。 很多 ops 依赖上下文(当前区域、当前模式、当前选中)。脚本里手动 bpy.data.objects.new() 创建的对象,如果不 link 到 collection、不设 active,bpy.context.active_object 就是 None。稳妥的做法是能不用 ops 就不用,用 bpy.data.meshes.new() 配 from_pydata() 或者 bmesh 直接造几何,可控性高一个量级。
坑二:EEVEE 在 -b 下不一定能渲染。 Blender 4.2 之后 EEVEE Next 对显卡和驱动有要求,CI 机器上大概率没有可用 GPU 上下文。我现在的做法是无脑用 Cycles CPU,24 samples、512 像素,一张两秒左右,缩略图完全够用。要出正式效果图再切 GPU,那是另一回事。
坑三:修改器不 apply 就导出,GLB 里是裸网格。 倒角、阵列、镜像全丢了。export_apply=True 解决。但注意这个参数和形态键、带驱动的修改器有冲突,如果你的资产要用形态键,得先手动 apply 再导出。
坑四:颜色发灰。 前面提过,Base Color 吃线性值,你从设计稿里拿到的 hex 是 sRGB。不转换的话,深棕色会变成灰扑扑的浅棕。代码里的 hex2rgb 就是干这个的,别嫌它长。
坑五:agent 会把日志读爆。 别让它去读 .blend、.glb 这类二进制文件,一读就是乱码,token 烧得飞快还没信息。用 .gitignore 把 out/ 排除掉,让它只读 .log 和 .json。
坑六:第五轮之后它开始打转。 前三四轮改得又快又准,到第五轮容易陷进细节里,比如反复微调一个 0.002 的倒角宽度。这时候人要接手,直接把参数写死,告诉它"就这个值,别再动了"。工具再强也有边际收益递减,认这一点不丢人。
我不想把话说满,说满了容易误导人。

程序化特别擅长:重复出现的、有明确规格的、靠数字描述的东西。地板砖、管道、栏杆、货架、纸箱、模块化建筑、机械零件、展台结构。这类资产的特征是——你把它拆开,每个部件都能写成"长宽高 + 位置 + 数量"。
程序化不擅长:有机形体、需要艺术判断的东西。角色、面部、风格化雕塑、手绘质感的道具。你让 Codex 生成一个"有点忧伤的木头小人",它给你的大概率是一个比例诡异的几何拼贴。
中间地带是最好用的:手工做一个"母版",然后用脚本做变体——缩放、随机旋转、替换部件、颜色扰动。游戏里那些"看起来都不一样但其实是同一套"的场景装饰,基本都这么来的。
还有一层值得提:几何节点(Geometry Nodes)。Codex 也能生成节点树,但那玩意儿底层是一堆 JSON 结构,改起来比 Python 难得多,报错信息也更难懂。我的建议是先用 Python 把逻辑跑通、参数调舒服了,稳定之后再考虑往节点里搬——反过来的顺序会让你痛不欲生。
写了这么多,其实想说的就一句:这套东西真正省掉的不是"建模"这个动作,是重复劳动那一层。
以前做一个木箱,你要建方块、倒角、上材质、打灯、渲图、导出,六步,重复六十次。现在这六步被写成了一个函数,你只需要想清楚"这六十个木箱之间到底差在哪"。这个思考过程,agent 替不了你。
我甚至觉得,以后评价一个技术美术,可能不看他会不会拉 UV,而看他能不能把一个需求准确地拆成参数表。因为参数表是可以被执行的,而"感觉"不行。
代码都在上面了,照着抄能跑。如果你也打算试试,我的建议是从最小的那个立方体开始,别一上来就想搞一条完整管线。先让一个箱子出来,再让两个箱子出来,剩下的会自己长出来。
毕竟我那个项目,第一天的成果也就是一个歪掉的木箱而已。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。