跳到正文
GitHub·· 7 天前AI 评分62

bl-fast:面向 AI 代码编辑的 Blender 批量编辑原语库

DogCutton/bl-fast

AI 导读

DogCutton 开源 bl-fast,一个面向 AI 代码编辑的 Blender 批量编辑原语库,用 numpy 进出的数据层操作替代逐个 bpy.ops 调用。

正文

面向 AI 代码编辑的 Blender 高速批量编辑原语库。

一句话:让 AI 写一段代码一次提交,而不是对每个对象调用一次工具。

原理与架构 → ARCHITECTURE.md 全链路(从 AI 到像素,九跳逐跳给证据)、Blender 各层(wm / RNA / BKE+DNA / Depsgraph / Draw / 撤销)、本工具的工作原理、以及它到不了的地方。 文中每个结论都标了是实测还是公开架构。 Blender 架构入门 → BLENDER_ARCHITECTURE.md 从「数据在哪 / 什么时候被算 / 什么时候被画」三个问题出发,讲 Blender 本身怎么运作。 适合想理解机制、而不是只想调 API 的读者。

想自己动手? → GETTING_STARTED.md 三条路(VSCode 扩展 / Blender 内建 / 命令行)+ 四个可跑练习 + 七个实测踩出来的坑。 你的 VSCode 已经装好 jacqueslucke.blender-development,开箱可用。

想装上它? → INSTALL.md 单文件库:复制进 Blender 的 scripts/modules 就能 import,卸载 = 删掉那个文件,零依赖。 (或者不装 —— 每个练习脚本都能就地运行。)

想知道那些架构结论怎么来的? → verify_architecture.py 把文档里标 ✅ 的声明逐条变成可重跑的检查,输出 PASS / PARTIAL / FAIL + 原始证据。

动手前先报身份

execute_code 连的是你当前开着的那个 Blender,不是某个沙箱。所以在任何会改动 场景的操作之前,先确认自己在哪个会话、哪个文件:

import os, bpy
print(os.getpid(), bpy.data.filepath or "(未保存)", bpy.app.background)

三条理由:

  • 会话身份:你可能同时开着一个 GUI 会话和一个无头进程,PID 是唯一能区分它们的
  • 文件身份:filepath 为空 = 这个文件从未保存,改动只存在于内存里
  • 可回滚性:撤销栈清不掉,操作一旦进去就留在那里

真实案例:本工具的开发过程中,在一次未保存的会话里做了 60+ 步测试操作, 把用户 Cube 的材质槽换掉了、多加了 3 个图像数据块、撤销栈涨到 15 MB —— 而这些改动在用户重启 Blender 后全部丢失,因为那个文件从未落盘。

如果一个场景对用户有价值,先让他 Ctrl+S,再动手。

AI 是怎么编辑 Blender 的

这一节解释为什么这个库长这样。记录的是实测出来的行为差异,不是设计偏好。

三种结构性差异

人类操作 AI 操作
输入 鼠标/键盘事件流,连续 一次性赋值(一次调用可以改几千个对象)
通知 事件循环自动带(每次移动都刷新) 必须手动补(update_tag / view_layer.update / tag_redraw)
感知 一个渲染画面(视觉场) 符号(只有 print 出来的文本)

第一条解释了 Edit 为什么要存在:人类「边走边渲染」,AI「改完再补通知」。 第三条解释了为什么「我改了但你看不到」不是 bug —— AI 根本没有「看」这个动作。

AI 实际执行的,只有四种动作

动作 代码 人类需要写吗
读 bpy.data.X.y / foreach_get 不需要(眼睛自动做)
写 obj.prop = value / foreach_set 不需要(事件流做)
通知 update_tag / view_layer.update / tag_redraw 不需要(事件循环做)
事务 undo_push / undo 不需要(停手后自动 push)

后两种人类永远不用写,AI 每次都逃不掉。

AI 改的是「输入」,不是「结果」

实测(把 Cube 的 location 设成 (3,0,0)):

赋值之后              location [3,0,0] ✓   matrix_world [0,0,0] ✗
view_layer.update()   location [3,0,0]     matrix_world [3,0,0] ✓

location 是输入,matrix_world 是算出来的结果。AI 写输入,结果要等求值。

AI「看到」的不是整个场景

把整个场景读一遍等于上下文爆炸。实测各层级的文本成本:

读到什么 字节
对象名 27
+ 类型 + 位置 117
一个对象的全部可写字段 2 910
三个对象都读 8 785
网格几何坐标 另算(默认完全不读)

读少了会改错,读多了会爆上下文 —— 这就是 describe / snapshot 存在的理由: 让「该看多少」变成可选项,而不是每次都盲查。

通用形状:读 → 操作 → 写回 → 验证

① 读:把实体翻译成符号        read / print / snapshot
        ↓
② 在符号空间里操作            (AI 真正「思考」的地方)
        ↓
③ 写回:把符号翻译回实体      edit / setattr / patch
        ↓
④ 验证:再读一次              回读 diff / 回读 snapshot

代码、Blender 场景、配置文件 —— 都是这个形状。 差别只在符号化的代价: 代码天然是符号,场景不是(所以必须先付一次 describe / snapshot 的成本)。

过期检测:anchor 与 Snapshot.base 是同一个东西

编辑代码 编辑 Blender
状态指纹 行锚点(3 字符 hash) Snapshot.base(导出时的字段值)
发现问题时 E_STALE_ANCHOR,拒绝 conflicts: [...],一个字都不写

两边都选择「拒绝」而不是「猜」。 不是巧合 —— 是同一个判断落在两个场景里。 差别在检测手段:文件小到可以整体 hash,场景大到只能逐字段值比对。

一个最要命的不对称

代码 Blender
改完在哪 磁盘(永久) 内存(没保存就没了)
出错怎么退 git checkout 撤销栈(32 步上限,且清不掉)
证据 git diff 没有等价物

同一个「提交」动作,在代码上是可回退的记录,在 Blender 上是可能随时蒸发、又撤不干净的内存状态。

→ 所以上一节那条规矩(先让用户存盘)不是礼貌,是唯一的安全网。


为什么快

Blender 有两套入口:给手指用的 bpy.ops(菜单按钮背后那套),和给程序用的 bpy.data。 前者每次调用都要付"撤销快照 + 依赖图重算 + 上下文校验"的固定开销,而这套开销 与你要编辑多少东西无关——所以逐个调用是最贵的用法。

一次 bpy.ops.mesh.primitive_cube_add() 实测 1504 µs,拆开是这样:

项目 耗时 占比 性质
撤销快照 783 µs 52% 记账
参数解析 / UI 通知 / 命名检查 ~590 µs 39% 杂项
依赖图重算 118 µs 8% 同步
ops 上下文校验(poll) 9.6 µs 0.7% 校验
真正造出 8 顶点 6 面几何 < 0.2 µs ≈0.01% 干活

干活占万分之一。 所以加速不是"把活干得更快",而是不干那些活。

旁证:同一个操作在空场景里是 1504 µs/个,在已有 100 个对象的场景里涨到 3667 µs/个。 它变慢了,说明时间花在跟场景规模相关的手续上,而不是花在造立方体上。

实测加速

环境:Blender 5.2.1 LTS / Release / 内嵌 Python 3.13.13 / numpy 2.3.4 / Windows 10

任务 慢路径 快路径 加速
创建 200 个立方体 + 随机摆放 bpy.ops 逐个 2572 ms 数据层批量 21.5 ms 119.7×
100 万顶点变形 Python 循环 3325 ms(外推) numpy 向量化 226 ms 14.7×
建 10 万三角面网格 from_pydata 447 ms numpy 直建 86 ms 5.2×
50 次改动提交 逐个 update() 93.8 ms 批量一次 1.67 ms 56.2×

结果与原路径逐位等价,不是牺牲正确性换速度:

  • 位置数组 np.allclose(..., atol=1e-6) 通过
  • 拓扑一致:两条建网格路径都得到 300000 顶点 / 300000 边 / 100000 面

设计四原则

  1. 批量:numpy 进 / numpy 出。绝不写 for v in mesh.vertices
  2. 延迟:编辑期间不碰依赖图,退出 with 块时一次性提交
  3. 撤销单位:一段编辑 = 一次 Ctrl+Z,而不是每一步一次
  4. 失败即回滚:Edit 块内任何异常(含校验失败)都自动退回编辑前状态

前三条从同一句话推出来:固定成本与批量大小无关,所以把它摊薄。 第四条来自另一句话:undo 不是事务 —— 它不会自己回滚。

加载

import sys
sys.path.append(r"F:/DeepSeek Harness Workspace/tools/bl-fast")
import bl_fast as bf

依赖:bpy(Blender 自带)、numpy(Blender 自带,无需安装)。

API

读取

函数 返回
positions(mesh) (N,3) float32 顶点位置
edge_verts(mesh) (E,2) int32 边 → 顶点索引
face_off_corners(mesh) (loop_start, corner_vert) 面拓扑
attr_vector(mesh, name) FLOAT_VECTOR 属性 → (N,3)
attr_scalar(mesh, name, dtype) 标量属性 → (N,)

写入

函数 说明
set_positions(mesh, arr) 一次写入全部顶点位置(校验形状)
set_positions_checked(mesh, arr) 同上,写完后回读比对,慢约一倍
transform_mesh(mesh, matrix) 4×4 矩阵批量变换(在 numpy 里算完再写回)
offset_mesh(mesh, delta) 平移所有顶点

建模

函数 说明
mesh_from_arrays(name, verts, faces) 从 numpy 直建网格,绕开 from_pydata;建完自动做拓扑校验
cube_mesh(name, size) 立方体(8 顶点 / 6 四边面)
grid_mesh(name, nx, ny, size) 规则四边形网格

对象

函数 说明
spawn_linked(name, count, mesh) 建 N 个共享同一 mesh 的对象(实例化)
spawn_many(name, meshes) 每个 mesh 建一个对象
set_locations(objs, locs) 批量写 location
set_scales(objs, scales) 批量写 scale(传 (3,) 会广播)
delete_many(objs) 一次删除一批
purge_orphans() 清掉无用户引用的 datablock

事务

with bf.Edit("ai: scatter cubes"):      # undo=True 默认
    ...
# 进入时:  在编辑之前留下还原点(undo_push)
# 退出时:  view_layer.update() 一次
# 异常时:  自动 bpy.ops.ed.undo() 退回进入时的状态

为什么在「进入时」push 而不是「退出时」:实测(Blender 5.2.1)—— 进入时 push 一次

  • 之后多次数据层修改,一次 Ctrl+Z 恰好回到进入时的状态;反过来若退出时才 push, 异常路径会跳过「编辑前」状态,一次 Ctrl+Z 会退到更早的地方。
参数 默认 说明
undo True 是否参与撤销栈。False 适合纯计算/临时对象
rollback_on_error True 异常时是否自动回滚
verify None 成功时的校验回调;它抛异常同样触发回滚

undo 本身不是事务,它不会在异常时自己回滚,所以才有 rollback_on_error。 实测:块内把值改到 9.0 后抛异常,回滚后读回 0.0(rolled_back=True, committed=False)。

反射:让 AI 看见「能做什么」和「现在是什么」

Blender 内置了说明书 —— 实测 Object 的 164 个属性里 149 个带 description(91%), 只算可写字段则是 106/109(97%)。所以 AI 不必背 API,读出来就行。

函数 作用 实测规模
describe(idblock, writable_only=False) 能力清单:类型/范围/枚举/文档 109 个可写字段
describe_text(idblock, writable_only=True) 上者的紧凑文本版 219 行 / 11.6 KB
snapshot(idblock) 当前值 → 可编辑的 Snapshot 99 字段 / 2.9 KB
patch(idblock, edited, on_conflict="raise") 只写回变化过的字段 —
# 看得见状态
print(bf.describe_text(obj, writable_only=True))   # 能改什么、怎么改

snap = bf.snapshot(obj)                            # 现在是什么(2.9 KB)
snap["location"] = [9, 9, 9]                       # 直接改
bf.patch(obj, snap)                                # 只同步这一个字段

snapshot 会自动跳过不可逆的字段 —— 实测它丢掉了 4 个 Matrix (matrix_world / matrix_local / matrix_basis / matrix_parent_inverse), 因为它们是派生数据,写回去也会被覆盖。

冲突检测(必须用,不能省)

patch 默认 on_conflict="raise":先检测全部,有冲突就抛异常、一个字段都不写。

策略 冲突时行为 实测
"raise" 抛异常,不写任何字段 rotation 保持用户设的 0.5
"skip" 跳过冲突字段,写其余 skipped: ["rotation_euler"]
"force" 冲突也覆盖 覆盖成 AI 的 1,1,1

关键:AI 改 A、你改 B 时不会误报冲突 —— 实测 conflicts: [], AI 的 location 生效,你的 rotation 原样保留。

反面教训(实测对比):不做 diff 的全量覆盖会把用户改的 rotation 抹回原值, 而且没有任何提示。

用法

import sys
import numpy as np
sys.path.append(r"F:/DeepSeek Harness Workspace/tools/bl-fast")
import bl_fast as bf

rng = np.random.RandomState(42)

with bf.Edit("ai: 200 个立方体随机散布"):
    cm   = bf.cube_mesh("CM")
    objs = bf.spawn_linked("Cube", 200, cm)      # 几何共享,内存只存一份
    bf.set_locations(objs, rng.rand(200, 3) * 10)
    bf.set_scales(objs, (0.4, 0.4, 0.4))

变形一个已有网格:

with bf.Edit("ai: 波浪变形"):
    p = bf.positions(mesh)              # 一次读进 numpy
    p[:, 2] = 0.3 * np.sin(5 * p[:, 0]) # 向量化运算
    bf.set_positions(mesh, p)           # 一次写回

这个路径不能做什么

实测边界(Blender 5.2.1 / Windows 10),不是推测。

上下文是「裸」的(必须先搭桥)

execute_code 运行时 area / region / space_data 全是 None,所以一部分 bpy.ops 的 poll 会失败:

ops poll
object.mode_set false(设了活动对象后变 true)
graph.select_all false(当前没有 GRAPH 区域)
transform.translate / mesh.primitive_cube_add / render.render true

所以规则不是「做不到」,而是先搭桥: bpy.context.view_layer.objects.active = obj + obj.select_set(True), 或 bpy.context.temp_override(window=..., area=..., region=...)(实测可用)。

编辑模式下的顶点写入会被静默丢弃 ★

8 顶点临时网格,编辑模式下:

阶段 读回
写入全零后,编辑模式内读回 0.0 ← 看起来成功了
退出编辑模式后读回 12.0 ← 原值回来了

API 不报错、读回也对、结果无效 —— 最危险的一类失败。 因为编辑期间几何的真源是 BMesh,mesh.vertices 只是投影。 set_positions 现在会在入口抛 RuntimeError 拦住它;要么先退出编辑模式, 要么改用 bmesh。

求值副本是「假的可写」

ev.location = (1,1,1)              -> OK(不报错)
ev.data.vertices[0].co = (1,1,1)   -> OK(不报错)
ev.data is orig.data               -> false

写得进,但下次求值就覆盖,且不进撤销栈、不保存。

只读属性

尝试 结果
obj.users = 5 ✗ read-only
mesh.vertices = None ✗ read-only
obj.matrix_world = ... ✓ 可写
obj.data = ... ✓ 可写

结构性限制(绕不过)

  • 模态操作:无法启动 modal operator 并用事件驱动(需要事件循环)
  • 鼠标/键盘注入:没有 API(除非 ctypes)
  • 不能中断:exec 阻塞,死循环只能等(推断,未实测)
  • 撤销栈历史:只能 push / undo,不能编辑
  • GPU 显存 / 视口内部状态:只能截图

状态存在哪

载体 跨调用持久
exec 的裸变量 ✗(命名空间每次新建,只预置 bpy)
模块属性(sys.modules) ✓(实测计数 1 → 2)
文件系统 ✓
Blender 场景 ✓

在里面渲染

方式 实测耗时(320×240) 用途
bpy.ops.render.opengl(write_still=True) 48.9 ms 快速预览
bpy.ops.render.render(write_still=True) 首次 27 973 ms 正式渲染(含着色器编译)
同上,第二次及以后 1 586 ms 比首次快 17.6×
gpu.types.GPUOffScreen(w, h) 可用 离屏,最程序化

首次渲染的 28 秒几乎全是一次性成本(着色器编译 / GPU 上下文初始化), 别拿它去估计后续渲染。

关键限制:bpy.data.images["Render Result"] 读不到 —— 实测无论渲染前后, 它的 size 恒为 [0, 0]、pixels 长度为 0。所以渲染结果必须落盘再从文件读:

scene.render.filepath = r"...\out.png"
scene.render.image_settings.file_format = "PNG"
bpy.ops.render.render(write_still=True)

img = bpy.data.images.load(r"...\out.png")     # 从文件读回
px = np.empty(len(img.pixels), dtype=np.float32)
img.pixels.foreach_get(px)                      # 实测 320x240 RGBA 可读

这是「AI 看到渲染结果」的完整闭环:渲染 → 落盘 → 读像素(或直接看图)。

让界面刷新

数据层修改不会自动设置重绘标记,所以用户的视口会停在旧画面上,直到有事件 (鼠标移动等)路过。Edit 默认在退出时强制刷新(redraw=True)。

实测(Blender 5.2.1):

调用 结果
bpy.context.screen.tag_redraw() ✗ 不存在(AttributeError)
area.tag_redraw() × 4 ✓ 0.05 ms(只设标记,异步)
bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1) ✓ 0.15 ms,同步绘制并交换缓冲

所以刷新要两步:先 area.tag_redraw() 设标记,再 redraw_timer 强制同步一次。 实测 24 帧连续刷新共 392.8 ms,每帧 0.15 ms —— 便宜到可以在动画循环里用。

with bf.Edit("ai: ..."):                  # 默认 redraw=True
    ...改动...
# 退出时: view_layer.update() -> tag_redraw() xN -> redraw_timer

with bf.Edit("ai: ...", redraw=False):    # 后台任务可以关掉
    ...

一个容易误判的点:截图工具(get_viewport_screenshot)总会看到最新状态, 因为它自己会强制 draw。所以「截图能看到」不等于「用户能看到」—— 验证界面刷新不能靠截图,只能靠人看。

已知限制

  • bpy.ops 不可替代的场合:mode_set(进编辑/雕刻模式)、渲染、导出、部分编辑器操作。 正确的规则不是"永远别用 ops",而是"ops 只用在数据层没有替代的地方"。
  • 编辑模式下改顶点会被静默丢弃:set_positions 已加守卫(抛 RuntimeError)。 编辑期间几何归 BMesh 所有,必须改用 bmesh 或先退出编辑模式。
  • snapshot 只覆盖参数层,不含几何(顶点/面是二进制缓冲,20 万顶点文本化要 3.2 MB / 245 ms)。几何请走 positions() / set_positions()。
  • describe_text 不小:一个 Object 是 219 行 / 11.6 KB(约 3k tokens)。 只要字段名和类型时可以自己筛一遍 describe() 的返回值。
  • mesh_from_arrays 要求等长面(全三角或全四边)。混合拓扑得走 bmesh。
  • 校验是选择性的:set_positions 只校验形状(拦住静默错误), 要证明数值真的写进去了得用 set_positions_checked(多一次 memcpy)。
  • mesh_from_arrays 的校验有价格:validate=True(默认)实测让建 10 万三角面 从 72.5 ms 变成 100.3 ms(+38.5%)。要极限吞吐就传 validate=False, 代价是失去越界索引 / 退化面的守卫。
  • 回滚依赖 undo 栈:rollback_on_error 靠 bpy.ops.ed.undo() 实现, 所以 undo=False 时它无效;回滚精度也受撤销栈状态影响(栈满后是环形淘汰)。
  • spawn_linked 共享 mesh:改一个对象的几何等于改全部。要独立几何请用 spawn_many。
  • 撤销有硬顶:undo_steps 默认 32,一次 Edit 占一步。改网格的每步快照约 0.8 MB (40 万顶点实测),栈满后环形淘汰。
  • 不改变 Blender 的内存策略:删除不归还操作系统,进程内存是高水位模型。
  • 非线程安全:Blender 的 Python API 不是线程安全的,不要多线程调用 bpy。
  • 未覆盖往返层:本库只优化"Blender 内部"的成本。如果调用方仍然每次工具调用只做一件事, 最大的那层杠杆(跨进程往返)就没吃到。

数据来源

本库的实测数据与设计推导,来自对 Blender 5.2.1 的一次内存/性能实测,包括: DNA 与属性化存储、Copy-on-Write 惰性求值、撤销的环形缓冲与快照成本、 bpy.ops 与数据层的成本解剖。 所有数字均为本机 Release 构建实测,换版本或换构建(debug/ASAN)不保证成立。

单次测量的方差不小:同一个「创建 200 对象」基准先后跑出 21.49 ms 与 10.22 ms。 没有重复取中位数,也没有误差棒 —— 这些数字应当读作量级,而不是精确值。


许可证

MIT © 2026 DogCutton

你可以自由使用、修改、分发(包括商用),只需保留版权声明。 软件按「原样」提供,不附带任何担保。

来源:GitHub · github.com