22 · Fast Mass Springs — a constant-Hessian implicit solver#

A mass-spring system stepped with implicit (backward) Euler solves, every step,

\[ x_{n+1} = \arg\min_x \; \tfrac{1}{2h^2}\,\lVert x - y\rVert_M^2 \;+\; E_\text{spring}(x), \qquad y = x_n + h v_n + h^2 g . \]

The spring energy \(E_\text{spring}=\sum_s \tfrac{k_s}{2}\,(\lVert x_i-x_j\rVert - r_s)^2\) is non-convex (the rest-length term), so a straight Newton solve must rebuild and re-factorize the Hessian every iteration. Liu et al. show a better way: introduce one auxiliary direction \(d_s\) per spring and write

\[ E_\text{spring}(x) = \min_{\lVert d_s\rVert=r_s}\; \sum_s \tfrac{k_s}{2}\,\lVert (x_i-x_j) - d_s\rVert^2 . \]

Now the step alternates a local projection (set each \(d_s\) to the rest length along the current spring direction) and a global solve. With the \(d_s\) fixed the objective is quadratic and its system matrix

\[ \big(\tfrac{1}{h^2}M + G^\top W G\big) \]

is built only from rest data — constant for the whole simulation. Factorize it once; every global step is then a back-substitution. That is the “fast” in fast mass springs.

Reference. T. Liu, A. W. Bargteil, J. F. O’Brien, L. Kavan, “Fast Simulation of Mass-Spring Systems”, ACM TOG (SIGGRAPH Asia) 2013. The same local/global + prefactored-global structure underlies projective dynamics (Bouaziz et al. 2014).

%matplotlib inline
import time
import numpy as np
import scipy as sp
import matplotlib.pyplot as plt
import simkit
import simkit.energies as energies
from simkit.edges import edges as mesh_edges
from simkit.solvers import block_coord
import utils

A cloth and its constant global matrix#

The cloth is a triangulated grid; its springs are the triangle edges (structural + shear). We pin the two top corners and let it drape under gravity. The simulator class below builds, once in __init__, the edge-difference operator \(G\) (so \(Gx\) stacks the spring vectors \(x_j-x_i\)), the spring weights \(W=\mathrm{diag}(k_s)\otimes I\), the lumped mass \(M\), and the constant system matrix \(H_c = M/h^2 + G^\top W G\), which it factorizes once with sp.sparse.linalg.factorized.

The implicit step is block coordinate descent on the split energy: a local_step that projects each spring onto its rest-length sphere (\(d = r\,(x_j-x_i)/\lVert x_j-x_i\rVert\)) and a global_step that is the prefactored solve \(H_c\,x = M y / h^2 + G^\top W d\). We drive the two with simkit’s block_coord.

class FastMassSpring:
    """Hanging cloth stepped with implicit Euler via local/global (Liu et al. 2013).

    The global system matrix Hc = M/h^2 + G^T W G is built from rest data only,
    so it is constant for the whole run and factorized exactly once. Each implicit
    step alternates a local projection (spring -> rest length) and a prefactored
    global solve, both driven by simkit.solvers.block_coord.
    """

    def __init__(self, X, T, ym=80.0, h=1.0 / 30.0, gravity=9.8, corners_idx=None):
        self.X, self.T = X, T
        self.n, self.dim = X.shape
        self.h = h

        # springs = unique triangle edges
        self.Edge = mesh_edges(T)
        self.ne = self.Edge.shape[0]

        # rest lengths, per-spring stiffness k = ym / l0^2
        self.l0 = np.linalg.norm(X[self.Edge[:, 1]] - X[self.Edge[:, 0]], axis=1)[:, None]
        self.ym = np.full((self.ne, 1), ym)
        self.k  = (self.ym / self.l0 ** 2).flatten()

        # edge-difference operator  G : flattened x -> stacked spring vectors  d_e = x_j - x_i
        rows = np.repeat(np.arange(self.ne), 2)
        cols = self.Edge.reshape(-1)
        vals = np.tile([-1.0, 1.0], self.ne)
        Sinc = sp.sparse.csc_matrix((vals, (rows, cols)), shape=(self.ne, self.n))
        self.G = sp.sparse.kron(Sinc, sp.sparse.eye(self.dim)).tocsc()
        self.W = sp.sparse.kron(sp.sparse.diags(self.k), sp.sparse.eye(self.dim)).tocsc()

        # lumped mass and gravity acceleration
        self.M    = sp.sparse.eye(self.n * self.dim).tocsc()
        self.gvec = np.tile([0.0, -gravity], self.n).reshape(-1, 1)

        # pin the two top corners (default) and keep their DOFs fixed at rest
        if corners_idx is None:
            ytop = X[:, 1].max()
            corners_idx = np.where((X[:, 1] >= ytop - 1e-9) &
                ((X[:, 0] <= X[:, 0].min() + 1e-9) | (X[:, 0] >= X[:, 0].max() - 1e-9)))[0]
        self.corners  = corners_idx
        self.pin_dof  = (corners_idx[:, None] * self.dim + np.arange(self.dim)).ravel()
        self.free_dof = np.setdiff1d(np.arange(self.n * self.dim), self.pin_dof)
        self.xp = X.flatten().reshape(-1, 1)[self.pin_dof]   # corners stay fixed at rest

        # THE CONSTANT global matrix  Hc = M/h^2 + G^T W G  (independent of x) -> factor once
        self.Hc   = (self.M / h ** 2 + self.G.T @ self.W @ self.G).tocsc()
        self.H_ff = self.Hc[self.free_dof][:, self.free_dof].tocsc()
        self.H_fp = self.Hc[self.free_dof][:, self.pin_dof].tocsc()
        self.solve = sp.sparse.linalg.factorized(self.H_ff)   # the ONLY factorization

    # ---- energy of the implicit step: inertia term + spring term ----
    def energy(self, x, y):
        d = (self.G @ x).reshape(-1, self.dim)
        l = np.linalg.norm(d, axis=1, keepdims=True)
        E_inertia = 0.5 / self.h ** 2 * float((x - y).T @ (self.M @ (x - y)))
        E_spring  = float(np.sum(0.5 * self.k[:, None] * (l - self.l0) ** 2))
        return E_inertia + E_spring

    def _make_full(self, xf):
        x = np.empty((self.n * self.dim, 1))
        x[self.free_dof] = xf
        x[self.pin_dof]  = self.xp
        return x

    # ---- local step: project each spring onto its rest-length sphere ----
    def local_step(self, xf):
        d = (self.G @ self._make_full(xf)).reshape(-1, self.dim)
        d = self.l0 * d / np.linalg.norm(d, axis=1, keepdims=True)
        return d.reshape(-1, 1)

    # ---- global step: the prefactored constant solve (back-substitution) ----
    def global_step(self, xf, d, rhs_inertia):
        rhs = (rhs_inertia + self.G.T @ (self.W @ d))[self.free_dof] - self.H_fp @ self.xp
        return self.solve(rhs).reshape(-1, 1)

    def step(self, x, v, n_inner=8):
        # one backward-Euler step via local/global (fast mass springs)
        y = x + self.h * v + self.h ** 2 * self.gvec     # momentum target
        rhs_inertia = (self.M / self.h ** 2) @ y
        global_step = lambda xf, d: self.global_step(xf, d, rhs_inertia)
        xf = block_coord(x[self.free_dof].copy(), global_step, self.local_step,
                         max_iter=n_inner, tolerance=0.0)
        x_new = self._make_full(xf)
        v_new = (x_new - x) / self.h
        return x_new, v_new

    def simulate(self, n_steps=240, n_inner=8):
        x = self.X.flatten().reshape(-1, 1).copy()
        v = np.zeros_like(x)
        states = [x.reshape(self.n, self.dim).copy()]
        for _ in range(n_steps):
            x, v = self.step(x, v, n_inner=n_inner)
            states.append(x.reshape(self.n, self.dim).copy())
        return states

    # ---- a Newton implicit Hessian, for the comparison below ----
    def newton_hessian(self, x):
        d  = (self.G @ x).reshape(-1, self.dim)
        He = energies.mass_springs_hessian_element_d(d, self.ym, self.l0)   # per-spring, depends on x
        Hd = sp.sparse.block_diag(He).tocsc()
        return (self.M / self.h ** 2 + self.G.T @ Hd @ self.G).tocsc()
X, T = utils.triangulated_grid(nx=25, ny=25, width=2.0, height=2.0)
sim  = FastMassSpring(X, T, ym=80.0, h=1.0 / 30.0, gravity=9.8)
print(f"{sim.n} nodes, {sim.ne} springs")
625 nodes, 1776 springs

“Constant Hessian”: local/global vs. Newton#

The local/global matrix \(H_c\) is built only from rest data, so it never changes — one factorization serves the whole run. A Newton implicit solve instead uses \(M/h^2 + G^\top H_\text{spring}(x)\,G\), whose spring block depends on the current positions and must be reassembled and re-factorized at every iteration. Let’s verify.

rng  = np.random.default_rng(0)
x_a  = X.flatten().reshape(-1, 1)
x_b  = (X + 0.3 * rng.standard_normal(X.shape)).flatten().reshape(-1, 1)
dHn  = sp.sparse.linalg.norm(sim.newton_hessian(x_a) - sim.newton_hessian(x_b))
print(f"Newton implicit Hessian, ||H(x_a) - H(x_b)|| = {dHn:.3e}   (nonzero -> rebuild + refactorize every iter)")
print(f"Local/global matrix Hc is state-independent   = {sp.sparse.linalg.norm(sim.Hc - sim.Hc):.3e}   (always 0 -> factor once)")
Newton implicit Hessian, ||H(x_a) - H(x_b)|| = 9.610e+05   (nonzero -> rebuild + refactorize every iter)
Local/global matrix Hc is state-independent   = 0.000e+00   (always 0 -> factor once)

Drop the cloth#

Released flat from rest, held at the two top corners, settling under gravity. The solver is unconditionally stable (implicit), so a large \(h=1/30\) and only 8 inner local/global iterations per step are plenty.

n_steps = 240
t0 = time.perf_counter()
states = sim.simulate(n_steps=n_steps, n_inner=8)
print(f"{n_steps} implicit steps in {time.perf_counter()-t0:.2f} s  (one factorization reused throughout)")
240 implicit steps in 0.16 s  (one factorization reused throughout)
def thin(states, target=120):
    k = max(1, len(states) // target); idx = list(range(0, len(states), k))
    if idx[-1] != len(states) - 1: idx.append(len(states) - 1)
    return [states[i] for i in idx]

lims = ((-1.6, 1.6), (-2.7, 1.3))
fig, anim = utils.animate_mesh(thin(states), T, lims=lims, fps=30,
    title="Hanging cloth, fast mass springs (implicit, prefactored)", pin_pts=X[sim.corners])
utils.show_video(fig, anim, "media/22_cloth.mp4", fps=30)

What the constant matrix buys per step#

Per implicit step, the local/global solver does a handful of back-substitutions through one cached factorization; a Newton step instead reassembles and re-factorizes its state-dependent Hessian at every inner iteration. Same problem, very different per-step cost.

xt = states[5].flatten().reshape(-1, 1)
vt = (states[5] - states[4]).flatten().reshape(-1, 1) / sim.h
y  = xt + sim.h * vt + sim.h ** 2 * sim.gvec
rhs_inertia = (sim.M / sim.h ** 2) @ y

# fast: 8 prefactored back-substitutions (reuse the one factorization)
t0 = time.perf_counter()
for _ in range(8):
    d = (sim.G @ xt).reshape(-1, sim.dim); d = sim.l0 * d / np.linalg.norm(d, axis=1, keepdims=True)
    _ = sim.solve((rhs_inertia + sim.G.T @ (sim.W @ d.reshape(-1, 1)))[sim.free_dof] - sim.H_fp @ sim.xp)
t_fast = time.perf_counter() - t0

# newton-style: assemble + factorize the state-dependent Hessian each iteration
t0 = time.perf_counter()
for _ in range(8):
    Hn = sim.newton_hessian(xt)[sim.free_dof][:, sim.free_dof].tocsc()
    _ = sp.sparse.linalg.spsolve(Hn, np.ones((len(sim.free_dof), 1)))
t_newton = time.perf_counter() - t0

print(f"per step (8 inner iters):  fast local/global {1e3*t_fast:6.1f} ms   vs   refactorizing {1e3*t_newton:6.1f} ms")
per step (8 inner iters):  fast local/global    0.9 ms   vs   refactorizing   43.5 ms

Takeaways#

  • The reformulation makes the system matrix constant. Splitting each spring’s energy with an auxiliary direction turns the global step into a quadratic solve with a fixed matrix \(M/h^2 + G^\top W G\). Factor it once at setup; every step and every inner iteration thereafter is a cheap back-substitution.

  • This is the same machinery as tutorial 21. Local step = closed-form per-element projection (here, a spring onto its rest length; for ARAP, a triangle onto its closest rotation). Global step = one prefactored linear solve. Block coordinate descent (simkit.solvers.block_coord) drives both.

  • Implicit + prefactored = fast and stable. A big timestep stays stable, and the per-step cost is dominated by sparse back-substitutions rather than repeated factorizations — exactly the win reported by Liu et al. (2013).