Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Volume Conservation Constraints

The simulation of incompressible or nearly incompressible materials is critical for many applications in computer graphics. In a force-based framework, incompressibility typically requires solving a complex Poisson equation for pressure. PBD offers a more direct and often simpler approach by enforcing volume conservation through geometric constraints.

Theory: Tetrahedral Volume Constraint Formulation

For volumetric objects discretized into a tetrahedral mesh, incompressibility can be approximated by constraining the volume of each individual tetrahedron to remain at its rest volume.

Definition 34.1.1 (Tetrahedral Volume Constraint). Given a tetrahedron defined by four vertices with positions and a rest volume , the volume constraint function is defined as: The constraint is satisfied when .

The gradients of this function with respect to the four vertex positions are:

These gradients—vectors proportional to the areas of the opposing faces—are used to calculate position corrections for the four vertices. Following the standard PBD projection mechanism, the Lagrange multiplier is computed as:

where is the inverse mass of vertex . The position corrections are then:

Implementation: Volume Constraint Solver

@ti.kernel
def solve_volumes(compliance: ti.f64, dt: ti.f64):
    """Solve volume constraints using PBD projection - implements Equation [(34.1.3)](#eq:volume:position_correction)"""
    alpha = compliance / (dt * dt)
    for i in range(num_tets):
        p_indices = ti.Vector([tet_ids[i, 0], tet_ids[i, 1], tet_ids[i, 2], tet_ids[i, 3]])
        w_sum = 0.0
        grads = ti.Matrix.zero(ti.f64, 4, 3)
        
        for j in ti.static(range(4)):
            ids = ti.Vector([p_indices[vol_id_order[j][c]] for c in range(3)])
            p0, p1, p2 = pos[ids[0]], pos[ids[1]], pos[ids[2]]
            grad = (p1 - p0).cross(p2 - p0) / 6.0
            grads[j, :] = grad
            w_sum += inv_mass[p_indices[j]] * grad.norm_sqr()
        
        if w_sum == 0.0: continue
        
        C = get_tet_volume(p_indices) - rest_vol[i]
        s = -C / (w_sum + alpha)
        
        for j in ti.static(range(4)):
            pos[p_indices[j]] += s * inv_mass[p_indices[j]] * grads[j, :]

The kernel computes gradients using the cross product formula from Equation (34.1.1). The vol_id_order array ensures proper vertex ordering for the cross products. The constraint violation C is computed as the difference between current and rest volume, and the Lagrange multiplier s follows Equation (34.1.2) with compliance softening.

@ti.func
def get_tet_volume(p_indices):
    """Calculate tetrahedral volume using scalar triple product - implements Equation [(34.1.1)](#eq:volume:tetra_constraint)"""
    p0, p1, p2, p3 = pos[p_indices[0]], pos[p_indices[1]], pos[p_indices[2]], pos[p_indices[3]]
    return (p1 - p0).cross(p2 - p0).dot(p3 - p0) / 6.0

This function implements the volume calculation from Equation (34.1.1) using the scalar triple product formula.

@ti.kernel
def init_physics():
    """Initialize physics state including rest volumes for constraints"""
    inv_mass.fill(0.0)
    for i in range(num_tets):
        p_indices = ti.Vector([tet_ids[i, 0], tet_ids[i, 1], tet_ids[i, 2], tet_ids[i, 3]])
        vol = get_tet_volume(p_indices)
        rest_vol[i] = vol
        if vol > 0.0:
            p_inv_mass = 1.0 / (vol / 4.0)
            for j in ti.static(range(4)):
                inv_mass[p_indices[j]] += p_inv_mass

The rest volumes are computed once during initialization and stored for constraint solving. Mass is distributed based on tetrahedral volumes, so vertices belonging to larger tetrahedra have more mass.