尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从贝塞尔曲线到FFD变形:三维空间自由变形的原理与实现

从贝塞尔曲线到FFD变形:三维空间自由变形的原理与实现 做建模和动画的朋友应该都有过这种经历一个造型调了半天总觉得某个局部弧度不对又不知道该拖哪个点好不容易拖对了旁边的面又跟着塌了。后来我接触到FFD变形Free-Form Deformation自由变形才意识到这类问题有一个更优雅的解法——先不谈怎么勾勒曲线而是把整个空间当作一块可以揉捏的果冻物体只是嵌在果冻里的气泡。而说起FFD就必须从贝塞尔曲线讲起因为它骨子里就是贝塞尔那套思想从一维走向三维的结果。这篇文章我想把这条线完整走一遍从贝塞尔曲线的数学基础到贝塞尔曲面再到FFD变形的实现。适合三类人看一是刚学计算机图形学的学生想搞懂贝塞尔和FFD到底怎么回事二是做3D建模、程序化生成的开发者想在自己工具里实现一个轻量变形功能三是资深建模师想理解自己熟悉的软件里Lattice修改器、FFD deformer背后的底层逻辑。我会尽量避开教科书式的推导用从业者的视角把原理和能落地的代码一起摆出来。1. 为什么要从贝塞尔曲线讲到FFD变形1.1 两种调形思路直接拖顶点 vs 整体控制日常建模中调整模型形态基本可以分成两种思路。第一种是直接在顶点层级操作选中一批顶点用移动、旋转、缩放去捏。这种方式足够精细配合软选择、权重刷子也能做出很自然的过渡但它有一个明显的毛病——当你需要“整个脑袋变大一点”“身体整体拉长”这类全局性变化时逐点操作效率低到让人崩溃而且很容易把原本顺滑的曲面捅出棱角。第二种思路是“不直接碰模型而是给模型套一个外部的控制装置”。FFD就是这么一种东西你在模型外面放一个格子框架lattice拖动格子上的控制点模型会被“带着”一起变形。这种间接控制的思路最大的价值在于非破坏性——原始几何顶点不会被直接修改你随时可以通过还原控制点来回到变形前的状态。很多游戏引擎里的布娃娃系统、动画里的夸张表情、甚至产品设计中快速出多版造型底层都有类似逻辑。1.2 一条主线曲线、曲面、空间的同一种控制逻辑我早年在学习时一度觉得“贝塞尔曲线”和“FFD变形”是两门完全不相关的课前者是画线后者是捏模型。直到自己动手实现了一遍才恍然大悟它们共享同一套数学骨架用少量控制点去影响大片几何形态。贝塞尔曲线是一维的一个参数 tn1 个控制点生成一条曲线。贝塞尔曲面是二维的两个参数 u、v一组控制点网格生成一张曲面。FFD则走到三维三个参数 s、t、u一组三维控制点阵列生成一个可以包住模型的“体”。所谓的变形就是把物体上每个点丢进这个三维贝塞尔体里用控制点的移动去重新计算它的坐标。理解了这条主线往后看代码和原理就会轻松很多FFD不是新发明它只是贝塞尔的“空间化延展”。2. 贝塞尔曲线的本质与两种求值方式2.1 线性插值的递归De Casteljau 算法先说一个所有学过基础数学的人都能秒懂的事实两点之间按比例取中间点就是最简单的线性插值。De Casteljau算法把这件事反复做就能得到贝塞尔曲线上的点。假设有三个控制点 P0、P1、P2取参数 t0.5。第一步在 P0P1 上取中点在 P1P2 上取中点得到两个新点 Q0、Q1。第二步在 Q0Q1 上再取中点得到点 R。这个 R 就是 t0.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所以曲线上的点始终落在控制多边形内部这叫凸包性质是贝塞尔曲线不会“飞”出控制点范围的原因第二当 t0 时只有第一个控制点权重为 1当 t1 时只有最后一个控制点权重为 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, dtypefloat) 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, dtypefloat) 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(ft{t}: de Casteljau{p1}, bernstein{p2})实测下来两种方式输出几乎一致de Casteljau 在极端参数下表现更稳定。读者可以试着把中间控制点拉高再观察整条曲线的变化范围就能直观感受到“全局影响”的含义。3. 张量积从一维曲线走向三维空间变形3.1 贝塞尔曲面把控制点换成控制网格从曲线到曲面最自然的做法叫“张量积”。曲线有一个参数 t曲面就给它两个参数 u、v曲线有一串控制点曲面就给它一个控制点网格。贝塞尔曲面的公式写成两层求和先把 u 方向看作“曲线的曲线”用 Bernstein 基函数把每一列控制点合成一个中间点再用 v 方向的 Bernstein 基函数把这些中间点合成最终结果。形象地说就像先横向铺一排排控制线再从这些线上纵向“织”出一张面。这个思路可以原封不动再升一个维度曲面从一个二维控制网格生成体就从一个三维控制点阵列生成。这里值得强调一点张量积结构让 FFD 的代码变得很简单三重循环、每重循环调一次 Bernstein 基函数就行。但代价是控制点数量按三次方增长——每一个方向 5 个控制点整个 lattice 就是 5×5×5125 个控制点。所以实际项目中 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 嵌入与求逆让模型“住进”latticeFFD 的完整流程分三步嵌入、计算、更新。第一步“嵌入”的核心是确定物体上每个点在 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): 生成规则latticediv是每个方向的段数控制点数段数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, indexingij), 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(axis0) maxs lattice.reshape(-1, 3).max(axis0) 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. 构造 lattice5x5x5 控制点 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(fv {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(ff {a1} {a2} {a2nx} {a1nx}\n) print(已输出 ffd_result.obj可以用支持OBJ的软件打开查看。)运行后你会看到一个中间鼓起的曲面。把代码里的控制点偏移量调大或者把 5×5×5 改成 3×3×3再观察效果变化你会对“控制点如何影响变形范围”产生很直观的感觉。这就是我推荐先跑 demo 再看文档的原因。注意这个实现只演示了“点云变形”。在真实网格上你需要把 mesh 的每个顶点都做一遍同样的变换面索引不变拓扑自然保持。4.4 实操心得网格密度、阶数与拉伸幅度的选择这一段是我实际用下来的经验写出来省得大家踩坑。lattice 的“段数”决定了每个方向的 Bernstein 基函数阶数。段数 n 表示有 n1 个控制点对应 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调试时反复对比初始网格和变形网格能帮你快速定位到底是哪个控制点在捣乱。这套“从曲线到空间”的思路值得你花一个下午亲手实现一遍。
返回列表