AI 知识开放计划 · 第 14 期

十分钟物理 · 网页版

Ten Minute Physics 的这套物理 demo,原本躺在一个 Unity 工程里—— 没装 Unity 的人根本打不开。这里用 JS 重写了四个,直接用手拖

XPBD
位置约束代替受力计算:先让点自由飞,再直接把它拽回该在的位置
4 个
绳子 · 布料 · 软体 · 流体,全部实时运算,不是录屏
0 依赖
纯 Canvas 2D 和原生 JS,手机也能跑;每个 demo 旁边就是它正在跑的代码
02

布料:拉伸、剪切、弯曲,三种约束叠出来的一块布

抓住任意一点拖动。把「撕裂」打开再狠狠一拽,布会沿着受力最大的地方裂开。

拖动布面 · 试试撕开
这块布是怎么搭出来的(正在跑的代码)
// 布料 = 三种距离约束叠在一张网格上
// 拉伸:相邻两点(撑住布不被拉长)
// 剪切:格子对角线(防止布像纸箱一样塌成平行四边形)
// 弯曲:隔一格的两点(越硬布越挺,越软越像丝绸)
for (let y = 0; y < rows; y++)
  for (let x = 0; x < cols; x++) {
    const p = id(x, y);
    // 拉伸
    if (x + 1 < cols) addEdge(p, id(x + 1, y));
    if (y + 1 < rows) addEdge(p, id(x, y + 1));
    // 剪切
    if (x + 1 < cols && y + 1 < rows) {
      addEdge(p, id(x + 1, y + 1));
      addEdge(id(x + 1, y), id(x, y + 1));
    }
    // 弯曲
    if (x + 2 < cols) addEdge(p, id(x + 2, y));
    if (y + 2 < rows) addEdge(p, id(x, y + 2));
  }

// 撕裂:一条边被拉到静止长度的 N 倍就直接删掉
if (tearing && len > e.rest * tearRatio) e.dead = true;
03

软体:会被压扁、又能自己弹回原形

抓起来往地上砸。它靠的不是弹簧,是「每个三角形都想守住自己的面积」。边柔度越大越面,面积柔度越大越瘪——两个都拉到底,它就彻底塌成一摊。

抓起来 · 甩下去
面积约束的那几行(正在跑的代码)
// 面积约束:三角形被压扁时把它顶回去
// 这是原仓库四面体「体积约束」SolveVolumes() 的二维版本
// C = A - A0,梯度是每个顶点对面那条边的法向
const alpha = areaCompliance / (dt * dt);

for (const t of tris) {
  const [i0, i1, i2] = t.ids;

  // ∇0 = ½(y1 - y2, x2 - x1),其余两个轮换
  const g0x = 0.5 * (py[i1] - py[i2]), g0y = 0.5 * (px[i2] - px[i1]);
  const g1x = 0.5 * (py[i2] - py[i0]), g1y = 0.5 * (px[i0] - px[i2]);
  const g2x = 0.5 * (py[i0] - py[i1]), g2y = 0.5 * (px[i1] - px[i0]);

  const area = 0.5 * ((px[i1] - px[i0]) * (py[i2] - py[i0])
                    - (py[i1] - py[i0]) * (px[i2] - px[i0]));
  const C = area - t.restArea;

  const w = invMass[i0] * (g0x * g0x + g0y * g0y)
          + invMass[i1] * (g1x * g1x + g1y * g1y)
          + invMass[i2] * (g2x * g2x + g2y * g2y);
  if (w === 0) continue;

  const lambda = -C / (w + alpha);
  px[i0] += lambda * invMass[i0] * g0x;  // 三个顶点一起往外推
  py[i0] += lambda * invMass[i0] * g0y;
  /* i1、i2 同理 */
}
04

流体:一股烟,绕着障碍物走

左边持续吹进来一股烟。按住画面拖动,圆形障碍物就跟着你的手指走,烟会实时绕开它。

拖动 = 移动障碍物
不可压缩投影 + 半拉格朗日平流(正在跑的代码)
// 欧拉流体:不追踪水滴,而是把空间切成格子记录速度
// 对应原仓库 17 Eulerian Fluid Simulator / FluidSim.cs
// ① 投影:让每个格子的净流入为 0(不可压缩),高斯-赛德尔迭代
for (let it = 0; it < numIters; it++)
  for (let i = 1; i < numX - 1; i++)
    for (let j = 1; j < numY - 1; j++) {
      if (s[i][j] === 0) continue;                 // 障碍物格子
      const sx0 = s[i-1][j], sx1 = s[i+1][j];
      const sy0 = s[i][j-1], sy1 = s[i][j+1];
      const sSum = sx0 + sx1 + sy0 + sy1;
      if (sSum === 0) continue;

      const div = u[i+1][j] - u[i][j] + v[i][j+1] - v[i][j];
      const p = -div / sSum * overRelaxation;      // 1.9 收敛快得多

      u[i][j]   -= sx0 * p;   u[i+1][j] += sx1 * p;
      v[i][j]   -= sy0 * p;   v[i][j+1] += sy1 * p;
    }

// ② 平流:每个格子回头去上游取值(半拉格朗日,永远稳定)
const x = i * h - dt * velocityU;
const y = j * h - dt * velocityV;
newField[i][j] = sampleField(x, y);

四个 demo 其实是同一段循环

绳子、布料、软体的区别,只在于「约束表里装了什么」。主循环一模一样:预测 → 解约束 → 反推速度,一帧切成若干子步反复做。

主循环

// 主循环:一帧切成 N 个子步,每个子步只解一遍约束
// 子步数比迭代次数管用得多 —— 这是 XPBD 相对老式 PBD 的关键
const sdt = dt / numSubSteps;

for (let s = 0; s < numSubSteps; s++) {
  // ① 预测:只受重力,先自由飞一小步
  for (let i = 0; i < n; i++) {
    if (invMass[i] === 0) continue;
    vy[i] += gravity * sdt;
    prevX[i] = px[i]; prevY[i] = py[i];
    px[i] += vx[i] * sdt;
    py[i] += vy[i] * sdt;
  }

  // ② 解约束:直接改位置,不算力
  solveDistance(sdt);
  solveArea(sdt);           // 软体才有

  // ③ 反推速度:位置变了多少,速度就是多少
  for (let i = 0; i < n; i++) {
    if (invMass[i] === 0) continue;
    vx[i] = (px[i] - prevX[i]) / sdt;
    vy[i] = (py[i] - prevY[i]) / sdt;
  }
}

距离约束(绳子和布料都是它)

// XPBD 距离约束:把两个粒子拉回静止长度
// 对应原仓库 ClothSimulationTutorial.cs → SolveStretching()
const alpha = compliance / (dt * dt);   // 柔度,0 = 绝对刚硬

for (const e of edges) {
  const w0 = invMass[e.a], w1 = invMass[e.b];
  const w = w0 + w1;
  if (w === 0) continue;

  let dx = px[e.a] - px[e.b];
  let dy = py[e.a] - py[e.b];
  const len = Math.hypot(dx, dy);
  if (len === 0) continue;

  dx /= len; dy /= len;              // 梯度 gradC,模长为 1
  const C = len - e.rest;            // 约束违反量
  const lambda = -C / (w + alpha);   // 拉格朗日乘子

  px[e.a] += lambda * w0 * dx;       // x += lambda * w * gradC
  py[e.a] += lambda * w0 * dy;
  px[e.b] -= lambda * w1 * dx;
  py[e.b] -= lambda * w1 * dy;
}