首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >我让 Codex 去指挥 Blender:一个人三天干完美术组两周的活

我让 Codex 去指挥 Blender:一个人三天干完美术组两周的活

原创
作者头像
大盘鸡拌面
发布于 2026-10-19 08:57:36
发布于 2026-10-19 08:57:36
690
举报

先说结论

上个月接了个活,给一个仓储题材的小游戏做场景道具。需求单摊开一看:六十多个木箱、托盘、货架,尺寸各不相同,还得能直接丢进 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 就够。

代码语言:javascript
复制
# 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 无头模式能不能用,跑这行:

代码语言:javascript
复制
blender -b --factory-startup --python-expr "import bpy; print('OK', bpy.app.version_string)"

看到版本号就通了。

这里有个参数值得单独说:​​--factory-startup​​。它的作用是跳过你本机的用户配置和第三方插件,用一个干净的出厂状态启动。团队协作的时候这个参数几乎是必须的——每个人的 Blender 设置都不一样,你不加它,本地跑得好好的脚本,换台机器或者上 CI 就炸,而且报错信息往往驴唇不对马嘴。


三、整条流水线长什么样

先上图,后面所有内容都是围着这张图转的。

图里有两个判断节点,一个是"退出码",一个是"看着达标吗"。第一个好理解,程序崩了就把报错喂回去。第二个才是这套方法的核心,也是最多人做错的地方。

Codex 看不见你的视口。 这是最大的认知差。你在屏幕前一眼就能看出"这个箱子倒角太硬",但对 agent 来说,那个 .blend 文件是一坨二进制,它读不了。

所以你必须把"视觉"翻译成"文件"。我用了三样东西:

  • 缩略图 PNG:每个资产渲一张 512×512 的预览,白底或透明底,固定机位。
  • 日志:stdout 里打每个资产的尺寸、面数、导出路径。
  • manifest.json:结构化的产物清单,agent 读这个比读日志省 token。

这三样凑齐,agent 才算长出了眼睛。


四、第一个能跑的脚本

别一上来就搞批量,先让一个立方体转起来。这个最小脚本我建议你亲手跑一遍,很多坑只有跑过才有体感。

代码语言:javascript
复制
# 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")

跑:

代码语言:javascript
复制
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 真的"看见"结果

这一步是整套流程的灵魂。我用一张时序图说明一次完整的迭代。

第三段是关键。怎么让它读图?Codex CLI 支持 ​​-i​​ 传图片:

代码语言:javascript
复制
codex -i out/thumbs/pallet_A.png "这个托盘的板条缝隙是不是太大了?按 12mm 的标准重新算一遍"

它会把图当输入看。这里有个小技巧:缩略图一定要固定机位、固定焦距、固定光照。因为 agent 判断"改前和改后有什么不同",靠的是对比。你每次换角度换灯光,它就被干扰了,容易得出"变化很大"这种废话结论。我在脚本里把相机方向写死成 ​​(1.0, -1.4, 0.9)​​ 归一化之后的向量,就是为了这个。

批量场景用 ​​codex exec​​,非交互模式,可以直接塞进 shell 脚本或者 CI:

代码语言:javascript
复制
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 每次启动都会读。相当于给它一份团队规范:

代码语言:javascript
复制
# 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 第几次能改对。我的体感是:写清楚了,通常两三轮收敛;不写,它能在一个坑里来回横跳五次。


六、完整实战:仓储道具批量生成

好,铺垫够了,上真东西。

目录结构

代码语言:javascript
复制
props-lab/
├── AGENTS.md
├── props.csv
├── build_props.py
├── run.sh
└── out/
    ├── glb/
    ├── thumbs/
    ├── manifest.json
    └── build.log

需求表 props.csv

代码语言:javascript
复制
name,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,#5e452f

​​n​​ 这一列在不同类型里含义不同:托盘是板条数,货架是层数,木箱暂时不用。这种"复用一列"的做法在配置表里很常见,别嫌脏,比开三张表省事。

生成器 build_props.py

代码语言:javascript
复制
"""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()

run.sh

代码语言:javascript
复制
#!/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.log

​​set -euo pipefail​​ 不是凑数的。Codex 判断成败主要看退出码,​​tee​​ 会把管道的退出码吃掉,加了 ​​pipefail​​ 才能把 Blender 的失败传出来。我第一次跑的时候没加,脚本崩了 Codex 还以为成功了,然后一本正经地分析一份空 manifest,那种荒谬感很难形容。

跑起来

代码语言:javascript
复制
chmod +x run.sh
./run.sh

7 个资产,CPU 渲染,大概 40 秒。日志长这样:

代码语言:javascript
复制
[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 assets

接进前端

manifest 是给引擎和前端看的,GLB 直接用:

代码语言:javascript
复制
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 删除。

目录
  • 先说结论
  • 一、这俩东西为什么能凑一块
  • 二、环境准备,半小时能跑起来
  • 三、整条流水线长什么样
  • 四、第一个能跑的脚本
  • 五、让 Codex 真的"看见"结果
  • 六、完整实战:仓储道具批量生成
    • 目录结构
    • 需求表 props.csv
    • 生成器 build_props.py
    • run.sh
    • 跑起来
    • 接进前端
    • 那三天实际发生了什么
  • 七、踩过的坑,一条一条说
  • 八、这套方法的天花板在哪
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档