布太硬,显式积分被刚度墙逼到一秒几万步,根本跑不动(痛点 a)。
关键想法:第一次把隐式 Euler 系统地用到布料上 —— 无条件稳定,于是可以"迈大步"。每帧解一个大型稀疏线性系统(用改良共轭梯度), 这是现代一切快速可变形求解器的祖宗。
vs 之前的显式质点弹簧:把"步长被刚度卡死"换成"每帧解一个线性系统",从此能跑真实刚度的布。
想象一下:你要训练一个机器人把一件 T 恤叠整齐。布料会皱、会自己叠到自己上面、 会和桌面摩擦 —— 这是机器人学里最难的一类操作。FLASH 这篇论文的野心是, 在 一块 GPU 上同时跑上千个高保真布料仿真,让强化学习策略 "几分钟"就训练出来,并且 零样本迁移到真实机器人上。
要看懂它,你会撞上一长串名词:隐式积分、Projective Dynamics、ARAP、 Signorini 条件、Coulomb 摩擦锥、非光滑 Newton、Schur 补、惯性近似、 块对角 Cholesky、稀疏直接求解器…… 本文为 只会高数、线代、机器学习的读者,把这些一块一块拆开,从头讲。 不假设你做过任何物理仿真。每一个概念都配一个可以拖动的交互演示, 并且用我们从零复现这篇论文时踩到的真实坑来印证。
FLASH 的全名是 Fast Learning via GPU-Accelerated Simulation for High-Fidelity Deformable Manipulation in Minutes(Luo 等,arXiv:2604.17513,2026 年 4 月)。 拆开看,它其实是两样东西缝在一起:
把这两个东西融合成一个 GPU 上能极致并行的迭代循环, 就是 FLASH。它能在单卡(论文用 RTX 5090)上把规模推到 超过 300 万自由度、30 FPS,并且同时跑成百上千个独立环境, 这正是强化学习(RL)疯狂采样所需要的。论文报告:用 FLASH 纯合成数据训练的折布策略, 不看任何真实演示就能迁移到真机叠毛巾、叠衣服。
为什么这件事值得一篇论文?因为现有的主流机器人仿真平台(比如 NVIDIA Isaac Sim) 在刚体上能轻松跑上千并行环境,但碰到可变形物体 (布、绳、软体)就要么慢、要么糙、要么并行不起来。FLASH 想填的, 就是"高保真可变形 × RL 级别并行"这块空白(详见 PART VI §6.1–§6.2 的全景对比)。
在讲任何细节之前,先把整个仿真的目标方程摆出来。这是论文的 Eq.1, 也是过去二十年几乎所有快速可变形仿真的共同起点。一帧仿真,本质上是在解一个优化问题:
这里每个符号都要认识 —— 它们会贯穿全文:
这一项是初学者最容易一眼跳过、却最该看懂的。我们把记号一层层剥开:
读成白话:越重的顶点,越"舍不得"离开它的惯性预测点(前面乘的 越大, 同样的偏移付出的代价越大)。这正符合直觉——质量大、惯性大、越难被弹性拽走。
把这个 读成一句白话:"找一组新位置 ,让它既别离惯性预测的地方太远 (第一项),又别把布拉伸/弯折得太狠(第二项)。" 第一项是"惯性 / 动量想让你去哪",第二项是"弹性想把你拉回什么形状",两者拔河,平衡点就是这一帧的结果。
为什么"一帧仿真 = 解优化"是对的、而不是随便写的?这就是隐式积分的数学, 也是我们要讲的第一块基础积木(§2.1)。先记住这个方程,它是后面一切的母体。
机器学习读者最熟悉的一句话是"数据就是燃料"。强化学习训练一个操作策略,动辄需要 上亿步的环境交互。如果每一步交互都要等一个慢仿真器,训练就是以"周"为单位的。 让仿真又快又能大批量并行,等价于把训练时间从"周"压到"分钟"—— 这就是 FLASH 标题里 "in Minutes" 的来历。
下面这张表把"为什么是这些技术选择"一次说清:
这张表也是全文的路线图:每一行的"FLASH 的选择"列,都对应后面一整节的讲解。 把这句话刻在脑子里 —— FLASH 不是发明了一个全新算法,而是把一串经典选择 (隐式 + PD + NCP + 稀疏直接解)重新组装成对 GPU 极度友好的形态。
下面这段伪代码就是论文 Algorithm 1 的骨架(多环境、GPU)。现在还不必懂每一行, 先看它的结构:一次性预分解 → 每帧检测一次接触 → 内层 local-global 迭代。 后文每一节都在解释这里的某一行。
# 一次性准备:把所有环境的系统矩阵拼成块对角,做一次 Cholesky 分解
A_bar = block_diagonal(A_1, ..., A_n) # §2.3 §4.2
L_bar = cholesky(A_bar) # 预分解,之后只回代
while simulating:
detect_collisions(all_envs) # §5.1 —— 关键:每帧只检测一次!
q_tilde = q + h*v + h*h * inv_M * f_ext # §1.2 惯性预测
for k in range(k_iters): # local-global 内层迭代 §2.3
p = project_local(G @ q) # 局部:每个三角形投影到最近旋转 §2.4
# 组装接触 Jacobian J、互补残差,做惯性近似 Schur 补 §3.4 §4.1
Z = J @ inv_M @ J.T + E # §4.1 —— FLASH 的核心简化
dlam = solve(Z, rhs) / (h*h) # 解接触力增量 §3.4
lam = lam + dlam
q = global_solve(L_bar, b + h*h * J.T @ lam) # 全局:一次稀疏回代 §2.3 §4.3
v = (q - q_prev) / h # 从位移反推速度接下来全文都围绕四个痛点展开。每一个基础概念、每一个 FLASH 的设计, 都是在解决其中某一个。先认识它们,后面读到对应技术时就会"哦,原来是为了治这个"。
布很"硬"(抗拉伸刚度 大;刚度就是"拉它一点点、它回弹多大力")。 一根又硬又轻的弹簧弹得特别快——它的固有振动频率 很高 ( 越大、质量 越小,弹得越快、 越高)。直觉上,东西动得越快,你就得用越小的时间步去"追"它, 否则一步迈太大就会冲过头、越冲越远。
这件事有个精确的门槛:显式积分要稳定,步长必须满足 。 布的刚度一高, 动辄上千,于是 被逼到 秒以下——一秒要算上万步,根本跑不动。 这堵翻不过去的"刚度墙",正是逼着我们改用隐式积分(§2.1 会证明隐式没有这堵墙)的原因。 下面的 Demo 1 让你亲手把步长推过墙、看显式当场爆炸。
两个物体要么分离(间隙 ,接触力 ),要么贴住 (间隙 ,接触力 ),绝不会两个都为正。这个"非此即彼"的逻辑 画出来是一个直角拐角,不可导 —— 而 Newton 法需要导数。摩擦的"黏住 / 滑动" 切换同样是个尖角。怎么对一个有尖角的方程做 Newton?这是 §3.1–§3.3 的主题。
弹性系统矩阵 是稀疏的(一个顶点只和邻居耦合)。但只要把接触力 严格地求解进去,标准做法里会冒出 —— 而稀疏矩阵的逆是稠密的 (§4.1 会解释为什么)。稠密就意味着没法跨环境分块、没法上千并行。 FLASH 用一个"惯性近似"把稠密的 换成对角的 ,奇迹般地保住稀疏 —— 这是它的招牌(§4.1)。
RL 要海量样本。把上千个独立布料环境的矩阵拼成一个块对角巨阵, 对它做一次 Cholesky,就等于同时分解了所有环境(块对角矩阵的 Cholesky 就是各块的 Cholesky)。 痛点 (c) 的稀疏性是这件事成立的前提(§4.2)。
左右两个弹簧-质点用同样的步长 h 积分,左边显式(Forward Euler)、 右边隐式(Backward Euler)。先把 h 调小:两边都稳。然后慢慢调大 h 或调大刚度 k —— 当读数里 越过 ,左边的能量条会冲破上限、当场"爆炸", 而右边永远稳稳衰减。这就是痛点 (a):显式有一堵翻不过去的刚度墙,隐式没有。
我们从最底层讲起:仿真就是反复回答"下一刻物体在哪"。牛顿第二定律 是一个二阶常微分方程,离散化时间后有两种走法。
把加速度写成差分。显式(forward)Euler 用当前位置算力: 。 隐式(backward)Euler 用下一刻的位置算力:
区别看似只是 vs ,但稳定性天差地别。对一个频率 的振子, 显式 Euler 每步把振幅放大 —— 能量只增不减,迟早爆, 而且 越大爆得越快。隐式 Euler 每步把振幅乘以 —— 无条件稳定,任意大的 都不会发散 (代价是引入一点数值阻尼)。这正是 Demo 1 演示的现象,也是布料仿真必须用隐式的原因(痛点 a)。
上面我们说隐式 Euler"用下一刻的力"。把它写成方程:把 代进 ,整理得 (这里 就是惯性预测, 是总势能,力等于势能的负梯度 )。这个方程看着像要解一个非线性方程组—— 但它恰好是下面这个标量函数(叫增量势能,incremental potential)的梯度等于零:
为什么?直接对 求梯度(高数里的链式法则 + "二次型 的梯度是 "): 。令它为零, —— 正是上面那个隐式 Euler 方程。 所以"解隐式 Euler"和"找 的最小值点"是同一件事。
对照 §1.2 的 Eq.1 —— 一模一样。"走一步隐式 Euler" 完全等价于 "最小化一个能量"。 这一步转换威力巨大:解方程是脆弱的(容易发散),而最小化能量可以借用优化里成熟的工具 (线搜索、信赖域、预条件),而且"能量下降"天然就是稳定性的保证。FLASH(以及它依赖的 PD) 整套机制,都建立在"把仿真当优化解"这个视角上。
布太硬,显式积分被刚度墙逼到一秒几万步,根本跑不动(痛点 a)。
关键想法:第一次把隐式 Euler 系统地用到布料上 —— 无条件稳定,于是可以"迈大步"。每帧解一个大型稀疏线性系统(用改良共轭梯度), 这是现代一切快速可变形求解器的祖宗。
vs 之前的显式质点弹簧:把"步长被刚度卡死"换成"每帧解一个线性系统",从此能跑真实刚度的布。
§1.2 的方程里有一项 —— 单个三角形的弹性能。这一节就把它彻底拆开。 核心是一个矩阵:形变梯度(deformation gradient)。
"变形"就是一个把静止位置 映到当前位置 的函数 。 高数里,一个多元函数在某点附近可以用它的雅可比矩阵(一阶导数排成的矩阵)做线性近似: ,其中 。直白说, 告诉你"在静止状态画的一根无穷小箭头 ,变形后变成了哪根箭头 "。 它就是形变梯度。一个三角形被假设是"线性单元"——内部每个点的形变方式都一样, 所以这个 在整片三角形上是常数,能闭式算出来。
线性单元意味着映射是仿射的:(一个线性部分 加一个平移 )。 平移 很烦人,但有个干净的消除办法——看边向量。取三角形顶点 , 对两条边作差,平移 就抵消了:
把"当前两条边"并成一个矩阵 、 "静止两条边"并成 ,上面两式合写成 , 于是
这就是形变梯度的闭式公式。注意 只跟静止形状有关,是个常数, 所以它的逆 可以在仿真开始时预计算一次;之后每一帧只要用当前位置拼出 , 一次矩阵乘法就得到 。
这正是复现代码里 mesh.py 干的事 —— 静止时建局部 2D 帧、预计算 ,每帧用当前位置组 :
# flash/mesh.py · _compute_rest_frame —— 每个三角形预计算静止帧(只跑一次)
p = rest[faces] # (T,3,3):T 个三角形,各 3 个顶点的 3D 静止坐标
e1 = p[:, 1] - p[:, 0] # 静止边 1 = 顶点1 - 顶点0
e2 = p[:, 2] - p[:, 0] # 静止边 2 = 顶点2 - 顶点0
# —— 建一个贴着三角形平面的正交局部 2D 坐标系 (u1, u2) ——
u1 = e1 / norm(e1) # 第一根轴:沿边 1 的单位向量
n = cross(u1, e2); n = n/norm(n) # 三角形法向(垂直于平面,只是拿来求 u2)
u2 = cross(n, u1) # 第二根轴:平面内、与 u1 垂直的单位向量
# 把两条静止边投影到 (u1,u2),得到 2x2 的静止边矩阵 Dr,再求逆
Dr = stack([[dot(e1,u1), dot(e2,u1)],
[dot(e1,u2), dot(e2,u2)]]) # (T,2,2)
Dr_inv = inv(Dr) # 预计算的逆 —— 之后每帧用 F = D @ Dr_inv
area = 0.5 * norm(cross(e1, e2))# 静止面积(叉积模长的一半),给弹性能加权一行行看它在干嘛:
e1, e2:从顶点 0 指向另外两个顶点的两条边——前面推导里的 、。u1, n, u2:造局部坐标系。u1 沿第一条边;n=cross(u1,e2) 是平面法向(垂直于整片三角形);u2=cross(n,u1) 于是落在平面内、又垂直于 u1。 就是三角形自己的"东 / 北"两个方向。Dr:把两条 3D 边各自点乘 、,得到它们的 2D 坐标,拼成 矩阵——这就是 。 才好求逆。Dr_inv:预计算的 。它只依赖静止形状,整场仿真不变,所以算一次、存起来,是后面每帧算 的关键省时点。area:静止面积。三角形越大、它的弹性能在总能量里权重越大(§2.3 会用到)。物理上有一条铁律:纯刚体运动(平移 + 旋转)不应该有任何弹性能 —— 你把一块布整体转个身,它没被拉伸,能量必须是零。所以弹性能只能依赖 里 "拉伸"的部分,不能依赖"旋转"的部分。极分解(polar decomposition) 正好把两者分开:
于是奇异值 就是两个主方向上的拉伸倍数: 表示该方向 不拉不缩, 表示拉长 1.5 倍, 表示压扁一半。一个纯旋转(没有任何形变) 意味着 ,即 、。最简单也最常用的弹性能 —— As-Rigid-As-Possible(ARAP) —— 就是直接惩罚" 离一个纯旋转有多远":
这里 是矩阵的 Frobenius 范数(把矩阵所有元素平方求和再开根, 等价于"把矩阵摊平成一个长向量再取欧氏长度")。右边那个等号是关键:因为旋转不改变 Frobenius 长度, 可以证明 —— 弹性能只数"每个方向拉伸偏离 1 多少", 完全不关心旋转了多少角度。这把"旋转不要钱"这条物理铁律,干净地写成了一个公式。
灰色是静止三角形,橙色是可拖动的变形三角形。橙色椭圆 = 把一个圆用 映射后的样子 (= 应变椭圆),青色十字 = 最近旋转 的朝向。 试试:(1) 整体转一下三角形 → 椭圆还是圆、能量 (旋转不要钱); (2) 把一个顶点拉远 → 椭圆被拉长、、能量上升; (3) 把一个顶点拖过对边把三角形翻面 → 、红色警告 亮起 —— 这就是 §2.4 要处理的"翻转"陷阱。
现在我们有了优化目标(§2.1)和弹性能(§2.2)。但 ARAP 能量里有个 SVD,整个 是非线性的, 直接上 Newton 每帧都要重新组装、分解一个大 Hessian —— 慢。 Projective Dynamics(PD) 是绕过这件事的精妙技巧,也是 FLASH 弹性部分的引擎。
PD 的诀窍是把每个弹性能改写成"到一个约束集合的二次距离",引入一个辅助投影变量 :
对 ARAP 来说,约束集合 就是"所有旋转矩阵",,最优的 就是最近旋转 。 这一改写让问题变得可分块,于是能用两个超便宜的步骤交替求解:
这里的 是什么?它是个固定的稀疏矩阵,作用是"从全体顶点位置 里, 线性地抽出第 个三角形关心的量"——对 ARAP 来说 就是那片三角形的形变梯度 (回忆 §2.2, 对顶点位置是线性的,所以能写成一个矩阵 乘 )。 因此只把"同一个三角形里的顶点"耦合起来,这正是 稀疏的原因 (一个顶点只和它所在三角形的邻居有非零耦合)——这一点到 §4.1 讲稀疏性时还会回来。
注意全局步里:矩阵 只取决于网格拓扑、权重、质量和步长 —— 全是固定的。 每次迭代变的只有右端项 。所以可以在仿真开始时把 做一次 Cholesky 分解 , 之后每一步全局求解都只是两次三角回代(前代 + 后代),快到飞起。 "预分解一次,反复回代" 就是 PD(以及 FLASH)速度的核心。 复现代码里这个结构清清楚楚:
# flash/pd_solver.py —— 预分解一次(仅在钉住点变化时重做)
self._L_factor = torch.linalg.cholesky(A_aug) # A = L L^T,分解一次
# ……每一帧的 local-global 内层迭代:
for _ in range(cfg.k_iters):
F = compute_F(q, faces, C) # 当前形变梯度
R = project_arap(F) # 局部步:投影到最近旋转(§2.4)
b = mass * q_tilde + h*h * stretch_rhs(...) # 组装右端项 b
q = torch.cholesky_solve(b, self._L_factor) # 全局步:一次回代,不重新分解这段代码就是上面整套理论的落地,逐行对应:
cholesky(A_aug):把 分解成 ,整场仿真只在构造时(和钉住点变化时)做一次。这是最贵的一步,但只付一次。compute_F + project_arap:局部步——算当前形变梯度、投影到最近旋转 (即 )。这一步对每个三角形独立,天然并行(§2.4 细讲)。b = mass * q_tilde + h*h * stretch_rhs(...):组装右端项 。mass * q_tilde 是惯性项 ,stretch_rhs 是 。每次迭代只有它在变。cholesky_solve(b, L):全局步——用已经分解好的 做两次三角回代解出 ,不重新分解。这就是为什么 次内层迭代依然便宜。一条两端钉住的链子,在重力下下垂。它从一条直线开始 —— 点"单步 local-global"看它一次次逼近悬链线:每一步先局部把每段投影到静止长度, 再全局解一次三对角线性系统(就是预分解过的 )。看右下读数: 最大位移越来越小,说明在收敛。勾上"自动迭代"让它跑到收敛; 调"弹簧权重"看刚度怎么改变最终形状。这就是 §2.3 那个"局部并行投影 + 全局一次求解"的循环。
PD 的家谱上有几篇必须认识的论文:
隐式 Euler 每帧要解非线性系统,完整 Newton 太慢、不稳。
关键想法:把弹性能写成"到约束流形的二次距离",于是隐式步分解成 并行的局部投影 + 一个常数矩阵的全局解。矩阵固定 → 预分解一次 → 实时、鲁棒、天生并行。这是 FLASH 弹性引擎的直接来源。
vs Baraff–Witkin 的每帧线性系统:把"每帧重新组装求解"换成"矩阵永远不变、只换右端项"。
原始 PD 只能用特定的二次投影能量,换个本构模型就不灵。
关键想法:证明 PD 其实是ADMM(交替方向乘子法)的一个特例。 一旦看成 ADMM,就能推广到任意非线性材料和硬约束,同时保留 PD 那个常数预分解矩阵。 这给了"PD 的 local-global 到底在优化什么"一个干净的理论解释。
vs 原始 PD:把"只能用固定几种能量"推广成"任意本构 + 硬约束,矩阵照样固定"。
游戏引擎要极简、极稳、极快的布料/软体,不想碰力和加速度。
关键想法:PBD 直接在位置层面迭代投影约束(Gauss–Seidel), 跳过力的计算。XPBD 给每个约束加一个柔度 并跟踪 Lagrange 乘子, 让刚度不再依赖迭代次数和步长 —— 这是 FLASH 接触里"柔顺约束 + 乘子"思想的近亲。
vs PD:PBD 更糙更快(无全局解);XPBD 给了它物理一致的刚度和"接触力估计",正是接触求解需要的。
局部步的核心是一行字:把形变梯度 投影到最近的旋转 (这个 就是 §2.3 里的投影变量 )。 听起来简单,却藏着一个让无数人栽过的坑。先看正确做法:通过 SVD , 最近旋转是 。这是经典的 正交 Procrustes 解。
上面的证明有个隐藏漏洞:我们只要求 正交,但正交阵分两种—— 的是真旋转, 的是镜像(reflection,照镜子的左右翻转)。 当三角形被压扁、拉过头时, 的行列式可能算出 —— 物理上这意味着三角形被翻了个面、 "里朝外"了。弹性求解器若把这种镜像当成"目标旋转",单元会反演(inversion), 能量曲面在这里有个虚假的低点,数值随即崩溃。
修法很经典:当 时,把 SVD 里最小那个奇异值对应的方向( 的最后一列)
翻号。几何上这相当于"宁可让那个方向多付一点拉伸能量,也不允许整片三角形翻面",
强行得到一个真旋转()。复现代码的 project_arap 一字不差地做这件事:
# flash/energy_arap.py —— 投影到最近旋转,并防止"翻面"
def project_arap(F):
U, S, Vh = torch.linalg.svd(F, full_matrices=False)
R = U @ Vh
# 检测是否成了镜像(det < 0 = 翻转),若是则翻最后一列的号
det_check = torch.linalg.det(R.transpose(-1, -2) @ F)
sign = torch.where(det_check < 0, -1.0, 1.0).unsqueeze(-1)
R_fixed = U.clone()
R_fixed[..., -1] = R_fixed[..., -1] * sign # 翻号 → 保证是真旋转
return R_fixed @ Vh逐行对照前面的数学:
svd(F):拿到 的三块。Vh 是 。R = U @ Vh:这就是上面证出来的最近旋转 (暂未处理翻转)。det_check = det(R.T @ F):检测翻转。 是对称拉伸 ,它的行列式 ;若为负说明发生了镜像(有奇异值"被迫取负")。sign = where(det_check < 0, -1, 1):负则准备翻号,正则原样。R_fixed[..., -1] *= sign:把 的最后一列(对应最小奇异值的方向)乘上 ,这正是"翻最小奇异方向的号"。return R_fixed @ Vh 得到保证 的真旋转。Demo 2 里把一个顶点拖过对边时弹出的红色"⚠ 翻转"警告,就是 的时刻 ——
正是这段代码在守门。这是复现时 [已解决] 的一个经典坑(见仓库 PAPER_QA.md B2)。
怎么定义一个"只惩罚形变、不惩罚旋转"、又好优化的弹性能?
关键想法:要求每个局部邻域尽量刚体(只许旋转平移), 能量就是 。用 local-global 交替求解:局部步用 SVD 抽出最佳旋转 (含 翻号),全局步解一个固定的 Laplacian 系统。 这个"local 抽旋转、global 解线性"的模板正是后来 PD 推广的原型。
vs 早期线弹性:把"对大旋转敏感、会鼓包"换成"旋转免费、对大变形也鲁棒"。
弹性讲完了,现在进入整篇论文真正难的部分:接触。复现笔记里有句话很到位: "弹性求解器很容易对上论文,接触才是全部的战场。" 先理解接触的逻辑。
考虑布上一个点和桌面。设 是它到桌面的间隙、 是桌面给它的法向支撑力。 物理只允许两种状态:分离(,则 ,桌子没碰到它哪来的力) 或接触(,则 ,可以有支撑力)。 绝不会"既有间隙又有力"。这个条件写成一行,就是大名鼎鼎的 Signorini 条件:
把这一行拆成三个条件读:(支撑力只能"推"不能"拉",桌子不会吸住布)、 (不许穿透)、(乘积为零 ⇒ 两个里至少一个是零)。 第三条是灵魂:它把"非此即彼"压成一个等式——要么 逼着 , 要么 逼着 。
那个 读作"互补"(complementarity),就是上面这套关系的缩写。 画在 – 平面上,可行状态是一个直角拐角(沿横轴 或沿竖轴 的 L 形),这个尖角不可导——这正是接触"非光滑"、 Newton 法会卡住的根源(§3.3 专门治它)。如果 gap 和 对未知量是线性的,整个问题叫 线性互补问题(LCP);加上摩擦的非线性锥后,叫非线性互补问题(NCP) —— 这就是 FLASH 里 "NCP" 的来历。论文的接触动力学(Eq.2)正是这个形式:
其中 是接触雅可比, 就编码上面的 Signorini(以及下一节的 Coulomb 摩擦)。
接触的"非此即彼"逻辑没法用普通方程写,怎么把它变成能稳定求解的数学问题?
关键想法:把刚体接触 + Coulomb 摩擦写成一个时间步进 LCP, 并证明它对任意接触配置都可解、不穿透。非穿透即 Signorini 互补, 摩擦锥用多面体近似塞进 LCP。这是"接触 = 互补问题"这一整套范式的奠基。
vs 惩罚力法:把"软弹簧 + 调参 + 会穿透"换成"严格互补约束 + 可证不穿透"。
法向(不穿透)讲完,还有切向(摩擦)。Coulomb 摩擦的规则也是"非此即彼":
把切向力约束在一个圆盘里,就是摩擦锥:
注意锥的半径正比于法向力 —— 压得越重,能提供的摩擦越大。这就是为什么你按住一张纸 能搓动它、轻轻一碰却搓不动。为什么叫"锥"?因为在 三维力空间里, 描出的是一个圆锥(顶点在原点、半张角 );在固定 的那一层切片上,它就是一个半径 的圆盘。
复现里把切向力投影回这个圆盘的代码非常直白,逐行就是上面规则的离散版:
# flash/contact.py —— 把切向冲量投影回 Coulomb 摩擦锥
def project_coulomb_cone(lam_n, lam_t, mu):
lam_n = lam_n.clamp_min(0.0) # Signorini:法向力非负(不能拉,只能推)
lt_norm = norm(lam_t, dim=-1) # 当前"想要的"切向力大小
cap = mu * lam_n # 锥半径 = μ·λn
scale = where(lt_norm > cap, cap/lt_norm, 1.0)
return lam_n, lam_t * scale # 超出锥 → 径向缩回边界(= 滑动)逐行读:
lam_n.clamp_min(0):把法向力截到非负——这一行就是 §3.1 的 Signorini "只推不拉"。cap = mu * lam_n:算出此刻摩擦锥的半径 。法向力越大,圆盘越大。scale = where(lt_norm > cap, cap/lt_norm, 1.0):核心判断。若"想要的"切向力 lt_norm 还在圆盘内(≤cap)→ scale=1,原样保留 = 黏住;若超出 → 按 cap/lt_norm 比例径向缩回边界,方向不变、大小压到 = 滑动。Demo 4 把这套逻辑做成一个可玩的物理场景:你调法向压力、切向拉力和 , 看方块到底是黏住还是滑动,以及当前接触力落在摩擦锥的哪里(空心点 = "想要的力", 实心点 = 投影后的实际力)。
右边大图是摩擦锥(),实心点是实际接触力、空心点是"需求"。 试试:(1) 切向拉力小 → 点在锥内、方块"黏住"不动; (2) 拉力调大越过锥边界 → 点跑到边界上、方块开始"滑动"; (3) 把法向压力调成负(抬起)→ 间隙打开、、 左上角 Signorini 图的点从竖轴跳到横轴 —— 没接触就没摩擦。
现在矛盾来了:求解优化要用 Newton(需要导数),但 Signorini / 摩擦的"非此即彼"是个尖角, 在切换点不可导(痛点 b)。怎么办?这就是论文 Eq.5–6 解决的问题。
把"弹性平衡"(来自最小化 的 )和"接触约束" 写在一起,对未知的位置 和接触力(Lagrange 乘子) 一起做一步 Newton 线性化, 就得到一个鞍点(saddle-point / KKT)系统。这里 KKT 指 Karush–Kuhn–Tucker 条件——约束优化最优解必须满足的一组方程:
上半部分是弹性( 来自 PD),下半部分是接触约束( 是接触雅可比)。 叫"鞍点"是因为解在 上是极小、在 上是极大。
对付不可导的尖角,经典武器是 Fischer–Burmeister 函数:
花一分钟验证这个等价(令 ):要 , 两边平方得 ,即 (至少一个为零); 而 又强制 不能为负。三个条件 正好凑齐 §3.1 的 Signorini。于是"一个尖角不等式逻辑"被压成了"一个等式 "。
这个 在原点 处仍不可微(那个尖角没消失,只是被搬到一个点上), 但它处处半光滑——可以套用 非光滑 / 半光滑 Newton: 一种允许在不可导点用"广义导数"(次梯度)代替普通导数的 Newton 变体,照样保持快速收敛。 实践中常用带 的平滑版 把那个点也磨圆。 FLASH 的接触求解器正是建立在 Macklin 等人这套"NCP 函数 + 半光滑 Newton"框架之上。
刚体 + 可变形 + 接触 + 摩擦,怎么在一个统一框架里稳定、可并行地解(痛点 b)?
关键想法:把"动力学 + 接触"整体写成 NCP,用 NCP 函数 (Fischer–Burmeister 类)把互补条件变成非光滑方程,再用半光滑 Newton 迭代。 每次迭代归结为解一个对称线性系统,在 GPU 上用共轭残差解, 还配了个"互补预条件子"。FLASH 的接触部分直接长在这篇之上。
vs 把接触当软弹簧的 PBD 系:把"软、近似、调参"换成"严格 NCP + 半光滑 Newton + 每步一个对称解"。
鞍点系统又大又不定(indefinite——它对应的"碗"既有向上弯的方向、又有向下弯的方向, 像个马鞍,所以叫"鞍点";这种矩阵不能直接 Cholesky)。标准手法是 Schur 补(Schur complement)—— 一个在约束优化里无处不在的消元技巧,它能把这个难缠的鞍点系统化归成一个正定的小系统。
想法很简单:那个 分块系统里,位置 的块 又大又好对付 (它就是 PD 那个预分解过的稀疏矩阵),接触力 的块又小又麻烦。 那就先用"好对付"的把"麻烦"的解出来。一步步做:
鞍点系统两行是 和 。 第一行解出 :
把它代入第二行,把 消掉,只剩 : ,整理同类项:
那个被夹出来的矩阵 就叫 在这个分块系统里的 Schur 补。 整套手法就是"先消大块、解小块、再回代"——约束优化、电路分析、统计里到处都是它。
这个 的维数等于接触数(通常远小于自由度数),而且通常正定,可以用 Cholesky 或共轭梯度解。 在接触/多体里它有个名字叫 Delassus 算子:直观上回答"如果我在每个接触上施加单位力, 整个弹性体会怎么响应"。论文 Eq.7–9 就是这套:
终于到了 FLASH 真正的核心创新(论文 Eq.10)。前面 §3.4 的 有个致命问题,正是痛点 (c)。
(弹性系统矩阵)是稀疏的: 只当顶点 共享一个三角形 —— 它是网格连接图上的"图 Laplacian",只记录"谁直接拉着谁的手"。但它的逆 是 完全稠密的,因为 是离散 Green 函数: 表示 "在 点戳一下, 点会动多少" —— 在一张连通的弹性网里,戳任何一点,所有点都会动一点。
于是 会把所有接触全耦合成一块稠密矩阵:跨环境也耦合、没法分块、 没法上千并行。FLASH 的解法干净利落 —— 把稠密的 直接换成对角的 :
这个近似为什么物理上站得住? 是惯性响应—— "在一个步长里, 给顶点一个单位冲量它会动多少,先不管邻居的弹性牵扯"。当步长 不大时, 接触冲量到位移的映射主要由惯性决定,弹性修正是二阶小量。所以 是真实接触柔度的一个相当好的近似,却便宜得多。论文 §IV-B 把它称作"轻量变体" (lightweight variant),并指出它"在改善 Schur 补稀疏性的同时保持数值鲁棒"。
每个格子是 的一块。勾选框开着(M⁻¹)时, 是块对角—— 每个环境一个独立小块,对角线外全空,于是可以"一次分解 = 同时解所有环境"。 取消勾选(切到精确的 A⁻¹)→ 整块涂满:所有接触、所有环境全耦合成一坨稠密, 可并行环境数瞬间掉到 1。拖动"环境数"滑块看块结构怎么长出来。这就是 FLASH 能上千并行的根。
有了 §4.1 的稀疏性,痛点 (d) 迎刃而解。论文 Eq.11–12 把 个环境的系统矩阵拼成一个大的 块对角矩阵:
个环境互相独立——第 1 块布永远不会碰到第 2 块布。所以把它们的系统矩阵拼在一起时, 只有对角线上的方块有内容,块与块之间全是零:
每个 是第 个环境的 PD 矩阵( 量级)。那些零块就是"环境之间没有任何耦合" 的数学体现——这正是 §4.1 那个惯性近似 拼命要保住的结构 (要是用稠密的 ,接触会把这些零块填上,环境就被搅在一起了)。
关键事实:块对角矩阵的 Cholesky 分解,就是各块各自 Cholesky 因子再拼成块对角。 验证一下——设每个块单独分解 ,把这些 拼成块对角 ,那么
(块对角矩阵相乘,就是对应块相乘——因为零块乘任何东西还是零。)而 Cholesky 因子是唯一的, 所以这个拼出来的 就是 的 Cholesky 因子。换句话说, "分解那个巨大的 " 从来不需要真的当成一个大矩阵来做——只要分别分解每个小块就行, 块与块之间零填充(fill-in)。
强化学习里,那 个环境通常是同一件 T 恤的上千份拷贝(同样的网格拓扑、同样的钉住方式), 只是初始状态/随机扰动不同。这意味着 —— 所有块完全一样! 于是连"分别分解每个块"都不必,只分解一个 、得到一个 ,让所有环境共用。 每一步要做的,只是把各环境不同的右端项 一起喂给同一个三角回代。复现代码里就是这么写的:
# flash/pd_solver.py —— 一个 L,批量解 B 个环境的右端项
# b 的形状是 (B, N, 3):B 个环境、N 个顶点、3 个坐标
rhs_flat = b.transpose(0, 1).reshape(N, B * 3) # 摊成 (N, B*3):B*3 个右端列
x = torch.cholesky_solve(rhs_flat, self._L_factor) # 同一个 L,一次回代全部解完
q = x.reshape(N, B, 3).transpose(0, 1) # 再 reshape 回 (B, N, 3)reshape(N, B*3):把 B 个环境 × 3 个坐标的右端项并排成一个有 列的矩阵。三角回代天生支持"多列右端"——多一列只是多算一遍前代/后代,矩阵 不变。cholesky_solve(rhs_flat, L):一次调用就把所有环境、所有坐标解完。底层是一个大的批量三角求解 kernel,把 GPU 的几千个核心全喂饱。self._L_factor 只有一个——这就是"分解一次、所有环境复用"。复现实测的批处理扩展性,和论文 Table II 同款"温和亚线性":
| 批大小(环境数) | 每步 ms | 每环境每步 ms | 环境·步/秒 |
|---|---|---|---|
| 1 | 4.46 | 4.46 | 224 |
| 8 | 7.16 | 0.89 | 1118 |
| 32 | 16.81 | 0.53 | 1904 |
| 64 | 29.42 | 0.46 | 2176 |
| 128 | 54.89 | 0.43 | 2332 |
| 256 | 107.4 | 0.42 | 2384 |
这张表怎么读?看"每步 ms"一列:从 1 个环境到 8 个环境,总时间只从 4.46 涨到 7.16 ms —— 环境数 ×8,时间却几乎没动,因为前 8 份订单基本是在"填满闲着的工人",近乎免费。 这段叫亚线性区。等核心被占满(约 32–64 环境往后),再加环境就只能排队, 总时间转为线性增长(64→128→256 时间大致翻倍)。
再看"每环境每步 ms"一列——这是真正的吞吐指标:从 4.46 ms 一路降到 0.42 ms(快了约 11×)并触底。这个 0.42 ms 就是 GPU 喂饱后的"计算下限", 再加环境也降不下去了。换算成吞吐(最右列),单卡稳定在约 2400 环境·步/秒。 对 RL 来说,这意味着用一块卡、一个进程,就能持续产出训练所需的海量交互 —— 而这一切的前提,是 §4.1 那个让矩阵保持稀疏 + 块对角的惯性近似。把链条连起来: 稀疏(§4.1)→ 块对角可独立分解(§4.2)→ 一个 批量回代喂饱 GPU → 上千环境近乎免费。
现在你已经认识每一块了,回看论文主循环(这次每一行都标了对应章节):
# 论文 Algorithm 1 —— 多环境 GPU 主循环(带章节索引)
A_bar = blkdiag(A_1, ..., A_n) # §4.2 块对角
L_bar = cholesky(A_bar); S_bar = inv(L_bar) # §2.3 §3.4 预分解
while simulating:
collision_detection(all_envs) # §5.1 每步一次(不是每迭代!)
q_tilde = q + h*v + h*h * inv_M * f_ext # §1.2 §2.1 惯性预测
for k in range(k_max): # local-global 内层
p_bar = project(G_bar @ q) # §2.4 局部投影到最近旋转
# 组装 J, E, g, h,做惯性近似 Schur
Z_bar = J_bar @ inv_M @ J_bar.T + E_bar # §4.1 招牌简化
dlam = (1/h**2) * solve(Z_bar, h_bar - J_bar @ S_bar.T @ S_bar @ g_bar) # §3.4
lam = lam + dlam # 累积接触力
q = S_bar.T @ S_bar @ (b + h*h * J_bar.T @ lam) # §2.3 §4.3 全局回代
v = (q - q_prev) / h # 反推速度先认一个记号:变量头上的横线(,代码里写成
A_bar 等)表示"把所有 个环境堆叠在一起"——前面 §4.2 讲的批处理就藏在这道横线里。
还有一个简写:(因为 、,见 §3.4),
所以代码里凡是 S_bar.T @ S_bar @ (...) 都读作"用预分解的 回代一次"。
现在逐行走:
准备阶段(循环外,整场只跑一次)
A_bar = blkdiag(A_1,...,A_n) — 把 个环境的 PD 矩阵拼成块对角巨阵(§4.2)。L_bar = cholesky(A_bar); S_bar = inv(L_bar) — 预分解一次(§2.3)。 就是论文那句"显式逆"的写法;实践中不必真的造出 ,把它当成"用 做回代"即可(§3.4 提醒过这句措辞要小心读)。块对角 ⇒ 这一次分解 = 同时分解所有环境。每一帧(while 循环)
collision_detection(all_envs) — 每步只检测一次、缓存接触对(§5.1 的灵魂)。把它挪进下面的内层 for 里,就会触发那个让布炸到 1500 mm/s 的能量注入。q_tilde = q + h*v + h*h * inv_M * f_ext — 惯性预测 (§1.2/§2.1):"假如没有任何内力,只有当前速度和重力,布会漂到哪"。它是这一帧优化的"靶心"。内层 local-global 迭代(for k,§2.3 的循环,Demo 3 演示的那个收敛过程)
p_bar = project(G_bar @ q) — 局部步(§2.4):G_bar @ q 从当前位置抽出每个三角形的形变梯度 ,project 把它投到最近旋转 (即投影变量 )。每个三角形独立、完全并行。# 组装 J, E, g, h — 准备接触线性化的零件(§3.3): 是接触雅可比、 是小正则、 和 是鞍点系统的右端块。Z_bar = J_bar @ inv_M @ J_bar.T + E_bar — FLASH 招牌(§4.1):用对角 inv_M()而不是稠密 来拼接触度量 ,于是 保持稀疏 + 块对角。dlam = (1/h**2) * solve(Z_bar, h_bar - J_bar @ S_bar.T @ S_bar @ g_bar) — 解接触力(§3.4 的 Schur 补):括号里 S_bar.T @ S_bar @ g_bar ,整体就是 ,解出接触力增量 。lam = lam + dlam — 把增量累积进总接触力(跨内层迭代热启动,frictional history)。q = S_bar.T @ S_bar @ (b + h*h * J_bar.T @ lam) — 全局步(§2.3/§4.3):,把惯性、弹性投影、接触力一起折中解出新位置。这一步只是对预分解的 回代一次(§4.3 讲它为什么不能落进 PCG 悬崖)。p_bar:local 和 global 交替 次,一步步逼近这一帧的平衡(§2.3 Demo 3 里"最大位移越来越小"就是它)。收尾
v = (q - q_prev) / h — 用这一帧的位移反推速度(隐式 Euler 的速度更新 )。§5.3 那个 G2D 速度阻尼、以及常规的全局阻尼,就加在这一步之后。把这张图竖着读一遍,本文前 14 节的每个概念都在里面各就各位: 隐式预测(§1.2/§2.1)→ 局部投影(§2.4)→ 惯性近似 Schur(§4.1)→ Schur 解接触力(§3.4)→ 全局回代(§2.3/§4.3), 外面套着块对角批处理(§4.2)和每步一次的碰撞检测(§5.1)。这就是 FLASH。
最后一块拼图:那个全局线性解 (或 )到底怎么算最快?这一节也是复现里 最戏剧性的一段,因为我们亲历了所谓的 "PCG 悬崖"。
解 ( 对称正定)有两种思路,性格完全相反:
条件数 是最大与最小特征值之比。把解 想成在一个碗 里往最低点滚(碗底正是解); 的特征值就是碗在各主方向上的"陡峭度", 就是这个碗的长宽比。CG 的误差经 步后满足:
要达到给定精度,所需迭代次数约为 —— 正比于 (注意是根号 κ,这已经是 CG 比朴素梯度下降聪明的地方,后者正比于 κ 本身)。 一句话记住:κ 涨 100 倍,CG 迭代次数约涨 10 倍。
三件事会把 κ 推高:材料越硬、时间步越小、尤其是冗余 / 近乎冲突的约束。 最后一条是接触的命门(§5.2 细讲):密集自接触里,相邻接触共享顶点、法向近乎平行, 于是约束矩阵冒出一堆近乎重复的行——两行几乎线性相关,把最小特征值 、 。此刻 CG 的迭代次数像撞墙一样飙升,性能从悬崖上掉下去。这就是 "PCG 悬崖"。
对付大 κ 的标准武器是预条件:找一个便宜的近似 ,改解 , 让 的条件数远小于 (几何上 = 把长窄峡谷"重新拉圆")。最简单的 Jacobi 预条件就是拿 的对角线当 。但复现里 Jacobi-PCG 没救回来—— 因为这里的病态是结构性的(约束彼此冗余、近乎线性相关),而 Jacobi 只会缩放对角, 根本消不掉"两行几乎一样"这种问题。实测:约 27 万自由度、开自碰撞时, Jacobi-PCG 撞到 1600 次迭代上限仍不收敛,622 ms / 1.6 fps。
换上 NVIDIA cuDSS(GPU 稀疏直接求解器)做 Cholesky 分解后,同一个场景 66.6 ms / 15 fps —— 42× 加速。为什么这么干净?因为直接法根本没有"迭代次数": 它的代价是 (因子 里非零元的个数),只跟稀疏结构有关、跟条件数无关。 κ 再大、约束再冗余,分解和回代的运算量一个都不多——悬崖被直接填平。
| 网格 | 自由度 | self-coll OFF(ms / fps) | self-coll ON(ms / fps) |
|---|---|---|---|
| 101×101 | 30 k | 3.54 / 282 | 5.75 / 174 ← 论文基准点 ×5.8 |
| 301×301 | 272 k | 11.4 / 88 | 66.6 / 15(PCG 时代是 1.6) |
| 601×601 | 1.08 M | 29.7 / 33.7 ★ 命中论文 30fps | 744 / 1.3 |
| 1001×1001 | 3.00 M ★ 论文目标 | 69.4 / 14.4 | 4847 / 0.2 |
几个值得记住的数字:关掉自碰撞时,100 万自由度跑到 33.7 fps,命中论文"百万级 @ 30fps"; 300 万自由度(论文目标 N)跑到 14.4 fps,落在论文的 2× 以内。 剩下的差距是内层迭代核函数的常数因子,不是任何算法悬崖 —— 这本身就是个好消息, 说明 FLASH 的算法结构确实没有规模上的硬墙。
从这里开始是复现的血泪故事 —— 它们比任何公式都更能让你真正"懂"接触求解。 第一个,也是最有启发的一个:一块布扔到地面上,开着自碰撞,它永远不肯停, 一直扭动 20 多秒,越扭越欢。用户当时的描述是"好像有鬼在控制"。
Projective Dynamics 在弹性部分是无条件稳定、只会耗散能量的。所以布不肯停,只能意味着 接触路径每步在往系统里注入能量。复现里用能量曲线坐实了这一点: 在释放后 1.75 秒,动能飙到 1.93 J,比释放瞬间的峰值 0.97 J 还高 —— 一个耗散系统的动能绝不可能超过初始峰值。积分器变成了一个能量泵。
根因是个极其微妙、却极其常见的错误。我们最初把碰撞检测放在了 local-global 内层循环里, 每次迭代都重新检测。后果:
一个顶点在 iter 1 距离 9.8 mm 触发接触 → 被推到 iter 2 的 10.2 mm 不再接触 → iter 3 弹性又把它拉回 9.5 mm 再次触发 …… 每一次"重新捕获"都注入一份全量冲量 → 布永远 settle 不下来。
论文 Algorithm 1 第 4 行其实把"碰撞检测"明明白白放在了内层循环之外(每步一次)。 我们照字面改成"每步检测一次、内层只读缓存的接触对"后: silk 布 7 次 lift→release 测试,最大末速从 1500+ mm/s 直接掉到 50 mm/s。 一行代码的位置,决定了系统是稳还是炸。
定位这种 bug 不能靠瞎试。复现里先列了 5 个可能机制,再设计 5 组只改一个变量的 对照实验(D1–D5),用"末 2 秒还在动多快"和"动能有没有超过释放峰值"两个指标判读:
| 实验 | 改的变量 | 动能峰值 | 末速 |v| | 结论 |
|---|---|---|---|---|
| D1 | 关掉自碰撞 | 0.97 J | 0 mm/s | 0.5 秒内静止 → 能量来自自碰撞路径 |
| D2 | 基准(复现用户视频) | 1.28 J | 856 mm/s | 永不静止 |
| D3 | 关掉自碰撞摩擦 | 1.24 J | 1418 mm/s | 更糟 → 摩擦本是耗散,去掉只剩注入 |
| D4 | 接触带 5mm→1mm | 0.90 J | 263 mm/s | 几乎静止 → 真凶是阈值附近的翻动 |
| D5 | 迭代 8→32 次 | 2.30 J | 1601 mm/s | 更糟 → 迭代越多注入越多 |
两个最反直觉的结果锁定了机制:D5(多迭代更糟)说明迭代本身在产能; D3(去摩擦更糟)说明摩擦是耗散源、不是问题源。真凶是 D4 指向的—— 顶点在接触阈值附近每次迭代翻动开/关,每翻一次注入一份冲量。把接触带从 5mm 收到 1mm, 阈值附近的边缘接触少了约 200×,注入随之消失。
一个小球落到地面反弹。默认(青色)是"每步检测一次"—— 回弹带恢复系数 < 1,能量曲线衰减、小球 settle,对应复现里的 50 mm/s。 勾选"每迭代重新检测"(橙色)→ 每步的 4 次内层迭代各注入一份冲量, 能量曲线一路爬升直到爆炸,对应复现里的 1500 mm/s。 这就是把碰撞检测放错位置的代价。
§4.3 讲了悬崖的现象,这里讲它的根因,并附两个复现里"以为是物理 bug、其实是数值 bug"的故事。
§4.3 说"冗余约束让 κ 爆",这里把它具体到接触上。把一块 51×51 的丝绸抓起来悬空, 自接触对能到 8 万个,(§4.1)是个 8 万维稀疏矩阵。 回忆 §4.1 那个矩阵元:两个共享顶点 的接触对 的耦合是
其中 是两个接触的法向。密集自接触里,相邻的接触法向几乎指同一个方向 (),于是 —— 的第 行和第 行几乎一模一样。
接下来就是 §4.3 那条 定律的反面教材。解 时, 病态意味着右端项里一丁点噪声,会被 倍放大到解 上 ()。 于是 CG 要么迭代到上限仍不收敛、要么吐出一个被噪声污染的巨大乱真 ; 这个乱真接触力经 变成一脚巨大乱真冲量,把布踹飞。 复现实测:纯 CG 让布炸到 24000+ mm/s(正常 settle 应是 ~50 mm/s), 加 Jacobi 预条件子也救不回来(仍 24000 量级)——因为病态是结构性的(约束彼此冗余), Jacobi 只缩放对角,消不掉"两行几乎一样"(正应了 §4.3 的结论)。
根治办法是 active-set(活跃集):那些"近乎重复"的约束其实是冗余的—— 保留其中一个就够了。每次迭代只挑出真正在穿透的接触(活跃集),把 缩小到只含这些行/列, 在这个满秩、良态的子空间里求解,κ 就回到正常范围。复现里这条(对应论文 Eq.8/9 的完整 Schur 解) 实现起来工程量大、还没打通,于是暂时禁用,退回到"位置投影 + 一个能量阻尼 workaround"—— 也就是下一节 §5.3 那个"擦地板"的 G2D。大规模密集自接触下 的良态求解,至今是开放问题。
早期一块自由下落(没碰任何东西)的布,竟在 0.4 秒内横向漂了 12.5 cm—— 自由落体怎么会拐弯?查到根因是 float32 精度。
为什么这里对精度这么敏感?自由下落时布几乎不变形,形变梯度 (近单位阵), 它的最近旋转 也近乎单位阵。可"几乎是单位阵"意味着真正有意义的信息藏在小数点后很靠后的位—— 而 float32 只有约 7 位有效数字,这些尾位被截断后,SVD/最近旋转投影会算出一个带方向偏置的微小假旋转。 更糟的是 float32 的 Cholesky 在求解时把这个偏置沿同一方向一帧帧累加(复现实测 的 f32 与 f64 之差最大到 1.144,是灾难级的),于是布稳定地往一边漂。 修法:网格常数与 PD 内部运算一律用 float64,只有对外存储用 float32。 教训刻在脑子里:精度 bug 常常伪装成物理 bug——别一看到"布拐弯"就去找力的方向,先怀疑数值。
另一次,把 从 1 一路调到 100,布的行为纹丝不动——摩擦像完全没生效,地面滑得像冰。 原因不在摩擦代码,而是一个量级失配:布被设得太硬,恢复弹性力约 0.84 N, 而那一点接触能提供的摩擦上限()只有约 0.002 N 量级——弹性力比摩擦上限大了约 400 倍。 结果是布刚一沾地,巨大的弹性回弹力立刻把它从地面顶了起来,于是 、 按 §3.1 的 Signorini 立刻 ——没有接触就没有法向力,摩擦锥半径 , 取多大都是乘以零。 教训:当一个旋钮在好几个数量级上都毫无反应,多半是那个变量压根没进有效代码路径 (这里是"根本没接触"),再怎么调它都没用。
这是整个复现里最诚实的一段,也是理解"论文级算法落地有多难"的最佳样本。 §5.1 把地面场景修好了,但悬空抓着的布(airborne)还是会永远微微扭动 —— 因为位置投影式接触每步仍在注入能量,地面场景靠桌面摩擦把它耗掉了,悬空场景没东西耗。
这里要看清一个能量账。§5.1 把碰撞检测挪到每步一次后,注入大大减小但没有归零—— 位置投影式接触(只做局部 、没做 §3.4 那个全局解)每步仍漏一点能量进系统。 在地面场景,这点漏进来的能量会被桌面的 Coulomb 摩擦不断耗掉, 收支大致平衡,布就静下来了。但把布拎到半空(两个角被夹爪钉住), 布对布的自接触摩擦极弱、又没有桌面——有进账、没出账,于是那点能量一直攒着, 布就永远微微扭动(airborne wriggle)。所以问题从来不是"悬空时注入更多",而是"悬空时没有水池接住漏水"。
复现里最终上线的"修法"叫 G2D,代码只有两行:
# flash/pd_solver.py —— G2.D:对"正在自接触"的顶点额外做速度衰减
# ⚠ 这是一个 workaround,不是 paper-faithful 的修法(见 notes/G2D_CAVEATS.md)
in_contact = sc._contact_count > 0
v_new[in_contact] *= (1 - contact_damping) # 默认 0.05逐行读,重点是它不碰物理、只擦能量:
sc._contact_count > 0:标出这一步处于自接触状态的顶点(接触计数大于 0)。
自由下落、没碰到任何东西的顶点不在内。v_new[in_contact] *= (1 - contact_damping):只把这些顶点的速度乘一个略小于 1 的数
(0.95)。这等于人为往接触处加一点黏滞阻力,把 bug 注入的多余能量"擦掉"。它对正在自接触的顶点多做一次速度衰减。效果立竿见影:悬空丝绸 30 秒,平均速度从 90 mm/s 降到 4.4 mm/s,视觉上完全静止。复现里还扫了一遍这个系数(越大越静): cd=0→90 mm/s、cd=0.02→28、cd=0.05→10、cd=0.20→2,最终选了 0.05。 但它错在哪,复现笔记说得毫不留情:
(1) 它擦地板,不关水管。 G2D 一点没减少接触投影的能量注入——它只是在出口处加了一个 人造的"能量水池"把漏进来的水舀走。病根(缺 §3.4 的全局解)还在,只是症状被盖住了。
(2) 那个 0.05 的调参曲线是非单调的,这本身就是混沌的告密。 扫系数时:cd=0.02→28 mm/s,cd=0.03→43,cd=0.05→10——注意 0.03 反而比 0.02 更糟。 一个有原理的阻尼/正则项(比如 §4.1 里 的那个 )应当是单调、可预测的; 而这里"多阻一点反而更动",说明背后是个混沌/双稳态系统(正是 §5.4 那回事), G2D 只是在一片乱流里碰运气找了个还行的常数。
(3) 它过阻了法向、还和已有旋钮打架。 真实 Coulomb 摩擦只作用在切向
(§3.2),而 v *= 0.95 是各向同性的——连"垂直于接触面"的法向速度也一起衰减了。
后果是慢慢拖动布时会感觉"发黏、发僵"。更乱的是它和 PDConfig 里本来就有的全局
damping 旋钮作用域重叠——两个阻尼旋钮、不同范围,复现笔记自己都嫌这设计"丑"。
论文 faithful 的修法(复现代号 G2.C)是:在每次 PD 内层迭代里真正解那个鞍点系统—— 用 §3.4 的 Schur 补解出接触力 、再用 §4.3 的 cuDSS 做 §2.3 的全局 更新、切向上 投影到 §3.2 的 Coulomb 锥。这样 和 一致地一起解出来, 接触不再"推出去-弹回来"地漏能量,压根没有多余能量需要去擦——G2D 就该彻底消失。
那为什么没上?卡在 §5.2 那个病态的 :密集自接触下 极度病态,CG 解出乱真
把布炸到两万多 mm/s,Jacobi 预条件也救不回来。要救它得上 active-set(每迭代把 缩到只含活跃约束的良态子空间),
工程量大、复现里没打通,于是暂时禁用(g2c_enabled = False),用 G2D 顶着。
所以 G2D 不是"懒",是它前面横着一道还没解决的数值难关(§5.2 的开放问题)。
最后一个故事,对机器学习读者尤其值得警惕。我们发现:同一个随机种子、同一份代码、 同一套配置,跑三次会得到宏观上完全不同的结果:
# seed=42,同一份代码,三次独立进程调用:
trial 0: |v|_last = 507 mm/s # 布在"沸腾"
trial 1: |v|_last = 17 mm/s # 布已静止
trial 2: |v|_last = 478 mm/s # 布在"沸腾"根因是两件事相乘:一个产生纳米级的微小差异,另一个把它放大成宏观分岔。拆开看。
数学上 ,但浮点数不是——因为每次加法都要把结果舍入到有限位数, 舍入顺序不同,结果就不同。一个 float32(约 7 位有效数字)的极端例子:把三个数 按不同顺序相加:
# float32 下,同样三个数、不同求和顺序:
(1e8 + 1) - 1e8 # = 0 :1e8+1 舍入回 1e8(那个 1 比 7 位精度还小,被吞了)→ 再减得 0
(1e8 - 1e8) + 1 # = 1 :先抵消成 0,再 +1 → 得 1同样的三个数,仅仅换了加的顺序,答案就从 0 变成 1 —— 100% 的相对误差,纯粹来自舍入。 这就是"加法不结合"在作怪:当数有大有小,谁先加、谁后加,决定了哪些小量被吞掉。
仿真里"把每个接触/三角形对某顶点的贡献累加起来"这一步,在 GPU 上是几千个线程同时往
同一个内存地址做 atomicAdd。原子操作只保证"不会两个线程同时写、把彼此覆盖掉",
但完全不保证它们到达的先后顺序——这个顺序取决于当次运行里线程调度的细微时序,
每次跑都可能不同。于是上一段那个"求和顺序"每次都在变,每次累加出的最后几位都略有出入。
代码一模一样,结果却带着一点不可控的随机尾巴。
纳米级的差异本来无关紧要——除非系统对初值极度敏感。布料接触正是这样一个系统, 而且它带着两个天然的"放大器",都在前面见过:
混沌系统里,微小差异随时间指数级拉开(蝴蝶效应)。布料-接触系统恰好有两个稳定结局 ("attractor"):settle 盆地(静下来)和 boil 盆地(持续沸腾)。 起点上那点 atomicAdd 带来的纳米噪声,被开关和病态反复放大,最终决定这次轨迹掉进哪个盆地—— 这就是为什么同种子能跑出 17(静止)和 507(沸腾)两种宏观物理。双稳态分岔。
怎么治?复现里没做,但方向清楚:用排序后的确定性 scatter(先按目标顶点把所有贡献用
CUB 基数排序排好,再分段规约)固定求和顺序,结果就逐位可复现;代价是排序比裸 atomicAdd 慢。
退一步也可以升到 float64(缩小但不消除——混沌照样放大)、或干脆接受方差(很多机器人 RL 就是这么做的)、
或加一个"确定性模式"开关按需切换。这是一道速度与可复现性之间的取舍题,没有免费的答案。
FLASH 并非凭空出现。它继承的是过去五年"把上千个仿真环境塞进一块 GPU、 让 RL 疯狂采样"这套范式 —— 只不过把它从刚体推向了难得多的可变形。
CPU 仿真喂不饱 RL 的采样需求,训练以"周"计。
关键想法:物理和策略网络都放在 GPU 上,同时跑成千上万个环境实例, 把 RL 采样吞吐提升一到两个数量级。这是 FLASH 直接继承的范式 —— 但 Isaac 这套主要为刚体/关节体设计,碰到可变形就吃力,FLASH 要填的正是这块。
vs CPU 多进程仿真:把"几十个 CPU 环境"换成"单卡上千 GPU 环境"。
FLASH 在和谁赛跑?下面是 2024–2026 这条赛道的地图。一句话总结这张图的张力: 跑得快的(MJX/Brax/Isaac)大多是刚体优先;能算可变形的(FleX/Warp/DiffTaichi/Genesis) 又往往在精度、接触保真或并行吞吐上各有取舍。FLASH 想同时拿下"高保真 + RL 级并行 + 快"。
需要能算布/软体、又能上 GPU、最好还可微的底座。
关键想法:FleX 用统一粒子 + PBD 算布/流体/软体(经典 GPU 可变形); Warp 是 Python→GPU 的可微核框架,含布料 VBD 求解器; DiffTaichi 用源码变换自动微分驱动一大批可微软体仿真。FLASH 与它们的区别在于走的是 严格 NCP(精度优先)这一路,而非 PBD(速度优先、软)。
vs PBD 系:把"软、近似、好并行"换成"严格互补、精确,再想办法保住并行"。
关键想法:把多种求解器(含可微 MPM)、Python 接口、照片级渲染、 语言驱动数据生成统一进一个平台。但要谨慎:它发布时"比 Isaac Gym 快 10–80×" 的宣传受到广泛质疑(对比的是单线程 MuJoCo、基准场景默认关自碰撞), 后被团队修订。引用它时应注明此争议。
vs 单一求解器引擎:野心是"全都要",代价是基准与稳定性的可信度问题 —— 这恰是 FLASH 自己的速度数字也需独立复现的警示。
研究布料操作得有标准任务和环境(叠布、挂衣、铺平)。
关键想法:SoftGym(建在 FleX 上)定义了绳/布/流体操作的基准词汇; GarmentLab(建在 Isaac Sim 上,PBD + FEM,100+ 件 3D 衣物)是最贴近 FLASH 的同代对手。 FLASH "Isaac Sim 处理可变形吃力"的论断,实质就是对 GarmentLab 底座的挑战。
vs 早期单任务 demo:把"零散演示"换成"统一基准 + 大量 3D 衣物资产"。
关键想法:图形学这边的高保真前沿 —— IPC 系用对数势垒严格保证不穿透, GPU 优化后比此前快 1–2 个数量级。注意对比:IPC 这类把一个高分辨率场景做到极致, 而 RL 世界(Isaac)是把上千个中等场景并行。FLASH 想跨在两者之间 —— 精度优先的求解器,RL 级别的并行。
vs PBD:把"软接触、可能穿透"换成"严格势垒、保证不穿透",代价是更贵。
把所有线索收束到一个问题:为什么 FLASH 非要同时做到"高保真 + 快 + 并行"? 因为零样本 sim-to-real 折布同时要求两件历来互相矛盾的事 —— (a) 仿真要足够准,接触/形变行为得贴近现实; (b) 要足够快且能大批量并行,才能做足够多的域随机化(domain randomization) 和大规模数据生成,让策略对"仿真和现实的差异"鲁棒。
真实布料数据贵且难标,能不能纯靠仿真训练再迁移?
关键想法:用域随机化在仿真里训练布料操作策略,不看任何真实演示 就迁移到真机。这正是 FLASH 零样本折布所属的传统 —— 而它的卖点是:用更高保真 + 更大规模的仿真, 把这条路推得更稳。师生蒸馏(teacher 用特权信息训练、student 在域随机化图像上模仿)是常配的训练范式。
vs 真机收集数据:把"昂贵的真实演示"换成"海量随机化仿真 + 一次迁移"。
读到这里,你应该能看懂论文的每一块。但诚实地说,无论论文还是复现,都有没解决的硬骨头 —— 这些正是值得做的研究方向:
PAPER_QA.md)。| 术语 | 解释 |
|---|---|
| 隐式 Euler | 用"下一刻"的力做时间积分,无条件稳定,等价于每步最小化一个增量势能(§2.1)。 |
| 增量势能 | ;最小化它 = 走一步隐式 Euler。 |
| 形变梯度 F | 把材料纤维从静止映射到当前的雅可比矩阵 ;编码拉伸 + 旋转(§2.2)。 |
| 极分解 / SVD | 把旋转 和拉伸 分开;通过 得最近旋转 。 |
| ARAP | As-Rigid-As-Possible 弹性能 ,只惩罚形变、不惩罚旋转(§2.2 §2.4)。 |
| Projective Dynamics | 把弹性能写成"到约束流形的距离",用 local(并行投影)+ global(固定矩阵解)交替求解(§2.3)。 |
| local / global 步 | local:每单元投影到最近约束(并行);global:解一个常数矩阵的线性系统。 |
| 预分解 Cholesky | 分解一次,之后每步只回代;PD/FLASH 速度的核心。 |
| 互补性 ⊥ | 两个非负量至少一个为零; 即 Signorini(§3.1)。 |
| Signorini 条件 | 不穿透的"非此即彼":要么有间隙无力,要么贴住有力(§3.1)。 |
| LCP / NCP | 线性 / 非线性互补问题;接触(含摩擦锥)写成 NCP(§3.1)。 |
| Coulomb 摩擦锥 | 切向力约束 ;锥内黏住、锥上滑动(§3.2)。 |
| Fischer–Burmeister | ,把互补条件写成(半光滑)方程(§3.3)。 |
| 非光滑 Newton | 允许在不可导点用广义导数的 Newton,用来解含尖角的接触方程(§3.3)。 |
| 鞍点 / KKT 系统 | 位置 与乘子 联立的不定线性系统(§3.3)。 |
| Schur 补 Z | 消掉 后只关于 的约化系统 ;又名 Delassus 算子(§3.4)。 |
| 惯性近似 | FLASH 把 Schur 里的稠密 换成对角 ,保住稀疏与块对角(§4.1,Eq.10)。 |
| Green 函数 | 的物理意义:"在 戳一下 动多少";连通弹性网里它是稠密的(§4.1)。 |
| 块对角 | 多环境矩阵拼成对角块;其 Cholesky = 各块 Cholesky,可同时分解所有环境(§4.2)。 |
| 条件数 κ | 矩阵"长宽比";CG 迭代次数 ,病态时暴涨(§4.3 §5.2)。 |
| PCG 悬崖 | 接触一多 飙升,迭代法性能从悬崖跌落;直接分解(cuDSS)无此问题(§4.3)。 |
| cuDSS | NVIDIA 的 GPU 稀疏直接求解器;把线性求解搬出关键路径(§4.3)。 |
| 自由度 DOF | 未知数个数;布有 个顶点即 个 DOF。 |
| 域随机化 | 在仿真里随机化物理/视觉参数,让策略对 sim-to-real 差异鲁棒(§6.3)。 |
如果你想读原始文献,下面是一个按教学顺序(不是按年份)排的清单 —— 每一篇都为下一篇铺路:
以下文献的题名、作者、出处均经核对。FLASH 本体的存在、作者与摘要级论断(>3M DOF @ 30 FPS on RTX 5090、 NCP 求解器、零样本 sim-to-real 折布)已在 arXiv 核实;其内部数值与 sim-to-real 结果为作者自报、尚待独立复现。 本文所有交互演示是 Canvas2D 教学玩具,数值不代表论文实际性能。
| 引用 | 出处 | 链接 |
|---|---|---|
| FLASH(本文主题) | Luo, Zhou, Zhang 等,arXiv 2026 | arXiv:2604.17513 |
| Large Steps in Cloth Simulation | Baraff & Witkin,SIGGRAPH 1998 | |
| Projective Dynamics | Bouaziz 等,TOG/SIGGRAPH 2014 | project |
| As-Rigid-As-Possible | Sorkine & Alexa,SGP 2007 | |
| Position Based Dynamics | Müller 等,JVCIR 2007 | |
| XPBD | Macklin, Müller, Chentanez,MIG 2016 | |
| Non-Smooth Newton Methods | Macklin 等,TOG 2019 | arXiv:1907.04587 |
| ADMM ⊇ Projective Dynamics | Overby 等,TVCG 2017 | project |
| Quasi-Newton for Hyperelastic | Liu, Bouaziz, Kavan,TOG 2017 | arXiv:1604.07378 |
| Implicit Time-Stepping (LCP) | Stewart & Trinkle,IJNME 1996 | DOI |
| Solvable LCP with Friction | Anitescu & Potra,Nonlinear Dyn. 1997 | DOI |
| Exact Coulomb (Nonsmooth Newton) | Bertails-Descoubes 等,TOG 2011 | DOI |
| FEM Simulation of 3D Deformables | Sifakis,SIGGRAPH 2012 课程 | notes |
| CG Without the Agonizing Pain | Shewchuk,CMU 1994 | |
| 接触/摩擦课程笔记 | Andrews, Erleben,SIGGRAPH 2022 | notes |
| Isaac Gym | Makoviychuk 等,2021 | arXiv:2108.10470 |
| DiffTaichi | Hu 等,ICLR 2020 | arXiv:1910.00935 |
| SoftGym | Lin 等,CoRL 2020 | arXiv:2011.07215 |
| GarmentLab | NeurIPS 2024 | arXiv:2411.01200 |
| Sim-to-Real Deformable RL | Matas 等,CoRL 2018 | arXiv:1806.07851 |
| cuDSS(GPU 稀疏直接求解) | NVIDIA | developer.nvidia.com/cudss |