做建模和动画的朋友应该都有过这种经历:一个造型调了半天,总觉得某个局部弧度不对,又不知道该拖哪个点;好不容易拖对了,旁边的面又跟着塌了。后来我接触到FFD变形(Free-Form Deformation,自由变形),才意识到这类问题有一个更优雅的解法——先不谈怎么勾勒曲线,而是把整个空间当作一块可以揉捏的果冻,物体只是嵌在果冻里的气泡。而说起FFD,就必须从贝塞尔曲线讲起,因为它骨子里就是贝塞尔那套思想从一维走向三维的结果。
这篇文章我想把这条线完整走一遍:从贝塞尔曲线的数学基础,到贝塞尔曲面,再到FFD变形的实现。适合三类人看:一是刚学计算机图形学的学生,想搞懂贝塞尔和FFD到底怎么回事;二是做3D建模、程序化生成的开发者,想在自己工具里实现一个轻量变形功能;三是资深建模师,想理解自己熟悉的软件里Lattice修改器、FFD deformer背后的底层逻辑。我会尽量避开教科书式的推导,用从业者的视角把原理和能落地的代码一起摆出来。
1. 为什么要从贝塞尔曲线讲到FFD变形
1.1 两种调形思路:直接拖顶点 vs 整体控制
日常建模中,调整模型形态基本可以分成两种思路。第一种是直接在顶点层级操作:选中一批顶点,用移动、旋转、缩放去捏。这种方式足够精细,配合软选择、权重刷子也能做出很自然的过渡,但它有一个明显的毛病——当你需要“整个脑袋变大一点”“身体整体拉长”这类全局性变化时,逐点操作效率低到让人崩溃,而且很容易把原本顺滑的曲面捅出棱角。
第二种思路是“不直接碰模型,而是给模型套一个外部的控制装置”。FFD就是这么一种东西:你在模型外面放一个格子框架(lattice),拖动格子上的控制点,模型会被“带着”一起变形。这种间接控制的思路,最大的价值在于非破坏性——原始几何顶点不会被直接修改,你随时可以通过还原控制点来回到变形前的状态。很多游戏引擎里的布娃娃系统、动画里的夸张表情、甚至产品设计中快速出多版造型,底层都有类似逻辑。
1.2 一条主线:曲线、曲面、空间的同一种控制逻辑
我早年在学习时一度觉得“贝塞尔曲线”和“FFD变形”是两门完全不相关的课:前者是画线,后者是捏模型。直到自己动手实现了一遍才恍然大悟,它们共享同一套数学骨架:用少量控制点去影响大片几何形态。
贝塞尔曲线是一维的:一个参数 t,n+1 个控制点,生成一条曲线。贝塞尔曲面是二维的:两个参数 u、v,一组控制点网格,生成一张曲面。FFD则走到三维:三个参数 s、t、u,一组三维控制点阵列,生成一个可以包住模型的“体”。所谓的变形,就是把物体上每个点丢进这个三维贝塞尔体里,用控制点的移动去重新计算它的坐标。
理解了这条主线,往后看代码和原理就会轻松很多:FFD不是新发明,它只是贝塞尔的“空间化延展”。
2. 贝塞尔曲线的本质与两种求值方式
2.1 线性插值的递归:De Casteljau 算法
先说一个所有学过基础数学的人都能秒懂的事实:两点之间按比例取中间点,就是最简单的线性插值。De Casteljau算法把这件事反复做,就能得到贝塞尔曲线上的点。
假设有三个控制点 P0、P1、P2,取参数 t=0.5。第一步,在 P0P1 上取中点,在 P1P2 上取中点,得到两个新点 Q0、Q1。第二步,在 Q0Q1 上再取中点,得到点 R。这个 R 就是 t=0.5 时曲线上对应的点。当 t 从 0 扫到 1,画出来的轨迹就是一条二次贝塞尔曲线。类似的,4个控制点做三轮插值,得到三次贝塞尔曲线。
这个算法在工程上有个巨大优点:数值稳定。它只涉及简单的加法和乘法,即便 t 非常接近 0 或 1,也不会像某些直接套多项式公式的写法那样出现明显的浮点误差。所以很多图形库里的贝塞尔求值默认都走 De Casteljau,而不是直接算多项式。
2.2 Bernstein基函数:每个点的权重怎么来的
如果说 De Casteljau 是“怎么做”的工程解法,那 Bernstein 基函数就是“为什么”的数学解释。对 n 次贝塞尔曲线,第 i 个控制点在参数 t 处的权重是:
B_i^n(t) = C(n, i) * (1-t)^(n-i) * t^i
其中 C(n, i) 是组合数。整条曲线就是所有控制点按这个权重加权求和。你只需要记住三条直觉:第一,所有权重加起来恒等于 1,所以曲线上的点始终落在控制多边形内部,这叫凸包性质,是贝塞尔曲线不会“飞”出控制点范围的原因;第二,当 t=0 时只有第一个控制点权重为 1,当 t=1 时只有最后一个控制点权重为 1,所以曲线首尾一定穿过端点;第三,除了首尾之外,中间控制点的权重在 (0,1) 区间呈现“先升后降”的趋势,这就是曲线会被控制点“拉”过去而不是“穿过”它的原因。
有一个经常被新手忽略的点:贝塞尔曲线的基函数是全局支撑的。意思是,移动任何一个控制点,整条曲线都会受影响,只是距离越远的点受影响越小。这就决定了它不适合做精细的局部微调,反而适合做整体轮廓的控制。这个特性在后面理解FFD的“局部扭成一团”问题时会很关键。
2.3 代码落地:30行实现一条曲线
理解原理后,代码非常简单。我贴一段可以直接运行的Python实现,同时给出 De Casteljau 和 Bernstein 两种写法,方便对照验证:
import numpy as np from math import comb def de_casteljau(points, t): """De Casteljau递归求值,数值更稳定""" pts = np.array(points, dtype=float) while pts.shape[0] > 1: pts = (1 - t) * pts[:-1] + t * pts[1:] return pts[0] def bernstein(n, i, t): """单次Bernstein基函数""" return comb(n, i) * (1 - t) ** (n - i) * t ** i def bezier_bernstein(points, t): """用Bernstein基函数直接加权求和""" pts = np.array(points, dtype=float) n = len(pts) - 1 result = np.zeros(pts.shape[1]) for i in range(n + 1): result += bernstein(n, i, t) * pts[i] return result if __name__ == "__main__": ctrl = [(0, 0), (2, 5), (5, 5), (7, 0)] for t in [0, 0.25, 0.5, 0.75, 1.0]: p1 = de_casteljau(ctrl, t) p2 = bezier_bernstein(ctrl, t) print(f"t={t}: de Casteljau={p1}, bernstein={p2}")实测下来两种方式输出几乎一致,de Casteljau 在极端参数下表现更稳定。读者可以试着把中间控制点拉高,再观察整条曲线的变化范围,就能直观感受到“全局影响”的含义。
3. 张量积:从一维曲线走向三维空间变形
3.1 贝塞尔曲面:把控制点换成控制网格
从曲线到曲面,最自然的做法叫“张量积”。曲线有一个参数 t,曲面就给它两个参数 u、v;曲线有一串控制点,曲面就给它一个控制点网格。
贝塞尔曲面的公式写成两层求和:先把 u 方向看作“曲线的曲线”,用 Bernstein 基函数把每一列控制点合成一个中间点,再用 v 方向的 Bernstein 基函数把这些中间点合成最终结果。形象地说,就像先横向铺一排排控制线,再从这些线上纵向“织”出一张面。这个思路可以原封不动再升一个维度:曲面从一个二维控制网格生成,体就从一个三维控制点阵列生成。
这里值得强调一点:张量积结构让 FFD 的代码变得很简单,三重循环、每重循环调一次 Bernstein 基函数就行。但代价是控制点数量按三次方增长——每一个方向 5 个控制点,整个 lattice 就是 5×5×5=125 个控制点。所以实际项目中 lattice 的分段数一般不会太高,原因后面会专门讲。
3.2 FFD的果冻模型:变形的是空间而不是模型
FFD 的原始思想来自 Sederberg 和 Parry 1986 年的经典论文。它做的事情可以概括成一句话:给模型一个“空间坐标”,然后变形这个空间本身。
我常用果冻来类比。想象一块透明的果冻里嵌着一些气泡(气泡就是模型顶点)。你用手指去按压果冻表面,果冻内部会跟着形变,气泡的位置自然也被带着移动。FFD 里的 lattice 控制点就是你的手指,而“果冻”就是由三维 Bernstein 基函数定义的空间场。
这比直接拖模型顶点高明在哪里?第一,你不需要逐个识别该选哪些顶点;第二,多个模型可以放进同一个 lattice 里一起变形,不用管它们之间有没有连接关系;第三,变形作用于空间坐标,模型的拓扑结构不会被破坏,表面法线、UV 这些属性也能相对稳定地跟随变化。
3.3 三者定位对比
我把贝塞尔曲线、贝塞尔曲面、FFD 这三者放一起做一个对照表,方便理清思路:
| 维度 | 控制对象 | 参数个数 | 典型应用 |
|---|---|---|---|
| 贝塞尔曲线 | 控制点列表 | t | 路径设计、字体曲线、动画曲线 |
| 贝塞尔曲面 | 控制点网格 | u, v | 曲面建模、地形生成、车壳设计 |
| FFD变形 | 三维lattice控制点 | s, t, u | 角色整体形变、产品多版本造型、网格匹配 |
核心规律只有一个:控制对象从“点”变成“线”再变成“空间”,但底层公式永远是 Bernstein 基函数的加权求和。搞懂这一点,FFD 对你来说就不再是黑魔法了。
4. FFD变形的完整实现与关键参数
4.1 嵌入与求逆:让模型“住进”lattice
FFD 的完整流程分三步:嵌入、计算、更新。第一步“嵌入”的核心是确定物体上每个点在 lattice 参数空间里的坐标 (s, t, u)。严格来说,你需要对初始 lattice 映射求逆,也就是反解一个三维非线性方程。
有一个常见的简化场景:如果初始 lattice 是一个均匀规则网格(很多软件里的 Lattice 默认状态就是长方体均匀格子),求逆可以直接用包围盒线性归一化。假设 lattice 包围盒在 X 方向从 min_x 到 max_x,那么物体点 p 的 s 坐标就是 (p.x - min_x) / (max_x - min_x),t 坐标、u 坐标同理。我下面给出的示例代码就是这个做法,在初始 lattice 为规则网格时是精确的。
如果初始 lattice 是扭曲的、非规则的,那就需要上牛顿迭代或者预计算张量积控制点的反函数,工程实现复杂度会明显上升。建议从简单场景开始,不要一上来就挑战任意形状的 lattice。
4.2 张量积贝塞尔体求值
嵌入完成后,变形计算本身就是一个三维张量积。设 lattice 中 (i, j, k) 位置的控制点为 P_ijk,物体点参数为 (s, t, u),则变形后坐标是:
F(s, t, u) = ΣΣΣ P_ijk * B_i(s) * B_j(t) * B_k(u)
这里是三重求和,i、j、k 分别遍历 lattice 三个方向的控制点下标。每项是由三个 Bernstein 基函数相乘得到的权重。也就是说,一个点的最终位置,是它周围所有 lattice 控制点“拉扯”共同作用的结果。控制点离得越近(基函数权重越大),影响越大;离得远,影响递减。
这里的代码和贝塞尔曲线的 Bernstein 版本几乎同构,只是从一重循环变成三重循环。
4.3 可直接运行的Python示例
下面这段代码我尽量写成了“自包含”形态:生成一个规则 lattice,构造一块 20×20 的平面点云,把 lattice 中间几个控制点向外拉,然后对点云做 FFD 形变,最后输出一个带四边形面的 OBJ 文件。你可以直接用 MeshLab、Blender 或 Windows 自带的 3D 查看器打开。
import numpy as np from math import comb def bernstein(n, i, t): return comb(n, i) * (1 - t) ** (n - i) * t ** i def build_lattice(min_corner, max_corner, div): """生成规则lattice,div是每个方向的段数,控制点数=段数+1""" xs = np.linspace(min_corner[0], max_corner[0], div[0] + 1) ys = np.linspace(min_corner[1], max_corner[1], div[1] + 1) zs = np.linspace(min_corner[2], max_corner[2], div[2] + 1) lattice = np.stack(np.meshgrid(xs, ys, zs, indexing="ij"), axis=-1) return lattice def bezier_volume(lattice, s, t, u): """三维张量积贝塞尔体求值""" lx, ly, lz, _ = lattice.shape result = np.zeros(3) for i in range(lx): for j in range(ly): for k in range(lz): weight = ( bernstein(lx - 1, i, s) * bernstein(ly - 1, j, t) * bernstein(lz - 1, k, u) ) result += weight * lattice[i, j, k] return result def ffd_deform(points, lattice, new_lattice): """对点集做FFD变形,初始lattice要求是规则网格""" mins = lattice.reshape(-1, 3).min(axis=0) maxs = lattice.reshape(-1, 3).max(axis=0) deformed = np.zeros_like(points) for idx, p in enumerate(points): s = (p[0] - mins[0]) / (maxs[0] - mins[0]) t = (p[1] - mins[1]) / (maxs[1] - mins[1]) u = (p[2] - mins[2]) / (maxs[2] - mins[2]) deformed[idx] = bezier_volume(new_lattice, s, t, u) return deformed if __name__ == "__main__": # 1. 构造 lattice:5x5x5 控制点 lat = build_lattice((-1, -1, -1), (1, 1, 1), (4, 4, 4)) new_lat = lat.copy() # 2. 移动中间控制点:把中间层向外“顶”起来 new_lat[2, 2, 2] += np.array([0, 0, 0.8]) new_lat[2, 1, 2] += np.array([0, 0, 0.4]) new_lat[2, 3, 2] += np.array([0, 0, 0.4]) new_lat[1, 2, 2] += np.array([0, 0, 0.4]) new_lat[3, 2, 2] += np.array([0, 0, 0.4]) # 3. 生成原始点云:一块 20x20 平面网格 nx, ny = 20, 20 xs = np.linspace(-0.8, 0.8, nx) ys = np.linspace(-0.8, 0.8, ny) pts = np.array([[x, y, 0] for y in ys for x in xs]) # 4. 执行变形 deformed = ffd_deform(pts, lat, new_lat) # 5. 输出OBJ(顶点+四边形面) with open("ffd_result.obj", "w") as f: for p in deformed: f.write(f"v {p[0]:.6f} {p[1]:.6f} {p[2]:.6f}\n") for iy in range(ny - 1): for ix in range(nx - 1): a = iy * nx + ix # 当前格子左上角顶点索引 f.write(f"f {a+1} {a+2} {a+2+nx} {a+1+nx}\n") print("已输出 ffd_result.obj,可以用支持OBJ的软件打开查看。")运行后你会看到一个中间鼓起的曲面。把代码里的控制点偏移量调大,或者把 5×5×5 改成 3×3×3,再观察效果变化,你会对“控制点如何影响变形范围”产生很直观的感觉。这就是我推荐先跑 demo 再看文档的原因。
注意,这个实现只演示了“点云变形”。在真实网格上,你需要把 mesh 的每个顶点都做一遍同样的变换,面索引不变,拓扑自然保持。
4.4 实操心得:网格密度、阶数与拉伸幅度的选择
这一段是我实际用下来的经验,写出来省得大家踩坑。
lattice 的“段数”决定了每个方向的 Bernstein 基函数阶数。段数 n 表示有 n+1 个控制点,对应 n 次多项式。我在做项目时,lattice 每方向段数很少超过 5,一般 3 到 4 段就够用。理由是:段数越高,每个控制点的影响范围越“全局”,移动一个点很容易带歪一片区域,而且高阶多项式可能出现非预期的振荡。需要更多细节时,正确的做法不是让单个 lattice 加密,而是把模型分解成多个局部 lattice,或用更细密的 lattice 只包裹需要变形的区域。
另外,变形幅度建议分步走。如果你一次把控制点拖得非常远,模型表面可能会出现剧烈扭曲甚至自交,这在动画绑定里是绝对的灾难。我习惯的做法是一帧只加一点点偏移量,连续多帧累加,或者手动分多次逐步到位。这样既能保证中间结果可控,出现问题时也容易回退。
最后,注意 lattice 的包围盒要稍微比模型大一圈,让模型完全“浸”在果冻内部,而不是贴着边界。因为参数坐标靠近 0 或 1 时,Bernstein 基函数的控制梯度会变得很陡,边界附近的变形容易产生生硬的“折边”。
5. 常见问题与排查技巧实录
5.1 模型局部“扭成一团”
表现是:移动一个控制点后,模型某一块区域像被拧麻花一样,出现异常折叠或穿插。这个问题的根本原因是 Bernstein 基函数是全局支撑的,你移动的控制点影响的不是“自己附近的一小块”,而是从当前位置到边界的一大段区间。
排查方向先看 lattice 阶数,阶数太高基本就是这个原因。再检查控制点移动幅度是否过大,尤其是相邻控制点之间差异悬殊时,曲面会产生剧烈挤压。解决办法是把 lattice 段数降回 3 到 4,或者在箱包区域附近增加一个独立的局部 lattice,而不是把单个控制点硬拉很远。
5.2 高阶lattice出现波浪振荡
这个我称之为“高阶基函数副作用”。类似于数值分析里的 Runge 现象,Bernstein 多项式在高阶时容易出现幅度不小的波动带。比如 6 段以上的 lattice,即使控制点只是轻微起伏,变形后的表面上也可能出现一圈圈波浪纹,这在渲染中非常明显。
遇到这种情况,我一般直接放弃高阶 lattice,改成多段低阶 lattice 拼接。说得直白一点:三次贝塞尔已经能表达绝大多数造型意图,别为了省事把一个 lattice 分成 8 段,那只会带来更多麻烦。
5.3 模型超出lattice导致形变炸裂
如果模型顶点在嵌入时算出的 s、t、u 落在 [0, 1] 区间之外,Bernstein 基函数依然会返回一个数,只是这个数可能非常离谱。最终效果就是模型某一块被“打飞”到远处,或者拉伸成一条诡异的长刺。
这个问题的排查最简单:变形前后对比坐标范围,检查是否有顶点坐标超过合理阈值。预防措施则是前面强调过的,先把 lattice 包围盒拉到大于等于模型范围,再开始变形。如果实在有顶点会跑出去,至少要在代码里做一次 Clamp,把参数限制在 [0, 1] 附近,不要直接让外推值参与计算。
5.4 问题排查速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 局部折叠、穿插 | 控制点移动幅度过大 | 分多次小步变形,减小单次偏移 |
| 表面波浪振荡 | lattice阶数过高 | 降为3到4阶,改用局部lattice |
| 模型某处炸飞 | 顶点参数超出[0,1]区间 | 调整lattice包围盒,代码加Clamp保护 |
| 变形不够顺滑 | 控制点密度太低 | 适当增加lattice段数,或局部加密 |
| 边界出现生硬折边 | 模型贴lattice边界太近 | 扩大lattice,留出安全边距 |
| 双向变形方向错误 | 参数轴与模型坐标轴对应错 | 检查s/t/u与x/y/z的映射关系 |
表格里最后一条也是我吃过亏的地方:不同软件对 FFD 参数轴的定义并不一致,同一个模型在不同工具里导入导出后,可能会出现“往 X 方向拉,模型却在 Y 方向变胖”的情况。做工具链对接时,务必验证数据接口里的向量顺序,头尾颠倒会让所有协作方摸不着头脑。
我在实际工作中越来越觉得,FFD 这类“空间级”变形方法的设计思路,其实比它本身的数学技巧更值得学习:当问题在原始层级上太难处理时,换一个更高维度的“空间”去间接操作,往往能收到奇效。如果你只是想让某个模型“整体胖一圈”或者“变高一点”,不需要精细雕刻,上 FFD 几十秒就能完成;做动画表情夸张、多版本产品造型时,它更是不可替代的利器。最后再分享一个小技巧:控制点移动前先保存一版初始 lattice,调试时反复对比初始网格和变形网格,能帮你快速定位到底是哪个控制点在捣乱。这套“从曲线到空间”的思路,值得你花一个下午亲手实现一遍。