Skip to content

A particle-to-grid fluid simulation

Material point methods keep material state on particles and use a temporary grid to exchange momentum. This fluid-block example is a compact application of matrix arrays, weighted interpolation and multidimensional atomic scatter.

An MPM block falling under gravity

Origin: adapted from Taichi's mpm88, the MLS-MPM example by Yuanming Hu, Copyright © The Taichi Authors, Apache-2.0. The scheme is retained; this version uses explicit Tack atomics, four separate kernels, a seeded initial block, a smaller grid and property checks. For the method and its history, see the authors' Taichi MPM project.

State on particles, scratch on a grid

Array Element Meaning
x 2-vector Particle position
v 2-vector Particle velocity
C 2×2 matrix Affine variation of velocity near the particle
J scalar Local relative volume
momentum 2-vector per grid node Accumulated momentum, then grid velocity
mass scalar per grid node Accumulated particle mass

All particles in this example have the same mass. The fixed grid size, spacing, timestep and material stiffness are tack.constant values. Particle count is a runtime kernel argument. The constitutive stress is a simple volume-based fluid model; this is not the multi-material snow/plasticity model in mpm99. The first scatter reads v and C before any kernel writes them, so the program fills them with zeros (and J with ones): a new array's contents are unspecified.

Scatter particle mass and momentum

A particle influences a 3×3 stencil. Its two coordinates produce quadratic B-spline weights, stored as a 3×2 local matrix. A stencil weight is the product of one weight from each axis:

@tack.kernel
def particle_to_grid(x, v, C, J, momentum, mass, n):
    for p in range(n):
        xp = x[p] / DX
        base = int(xp - 0.5)
        fx = xp - base
        weights = tack.mat(0.5 * (1.5 - fx) ** 2, 0.75 - (fx - 1.0) ** 2,
                           0.5 * (fx - 0.5) ** 2)
        stress = -DT * 4.0 * STIFFNESS * VOLUME * (J[p] - 1.0) / DX**2
        affine = tack.mat2([stress, 0.0], [0.0, stress]) + MASS * C[p]
        for i, j in tack.ndindex(3, 3):
            offset = tack.vec2(i, j)
            dpos = (offset - fx) * DX
            weight = weights[i, 0] * weights[j, 1]
            tack.atomic_add(momentum, base + offset, weight * (MASS * v[p] + affine @ dpos))
            tack.atomic_add(mass, base + offset, weight * MASS)

affine @ dpos is a matrix-vector product. weight * (...) scales the vector. Many particles visit the same node, so both grid updates require atomics. The momentum vector is updated component by component atomically; the next launch waits for all components to finish before reading it.

The stencil must fit inside the grid. Initial positions and boundary handling keep this small example in its supported range; Tack does not check the indices unless debug mode is on. If extending the model to emit particles or change boundaries, check that invariant explicitly.

Update the grid, then gather

The grid-update kernel divides momentum by mass at occupied nodes, adds gravity and prevents outward velocities near the box edges. The array now holds grid velocity. A second interpolation transfers it back to particles:

@tack.kernel
def grid_to_particle(x, v, C, J, grid_v, n):
    for p in range(n):
        xp = x[p] / DX
        base = int(xp - 0.5)
        fx = xp - base
        weights = tack.mat(0.5 * (1.5 - fx) ** 2, 0.75 - (fx - 1.0) ** 2,
                           0.5 * (fx - 0.5) ** 2)
        new_v = tack.vec2(0.0, 0.0)
        new_C = tack.mat2([0.0, 0.0], [0.0, 0.0])
        for i, j in tack.ndindex(3, 3):
            offset = tack.vec2(i, j)
            dpos = (offset - fx) * DX
            weight = weights[i, 0] * weights[j, 1]
            gv = grid_v[base + offset]
            new_v += weight * gv
            new_C += 4.0 * weight * gv.outer_product(dpos) / DX**2
        v[p] = new_v
        x[p] += DT * new_v
        J[p] *= 1.0 + DT * new_C.trace()
        C[p] = new_C

The outer product gv.outer_product(dpos) reconstructs C; trace() updates the relative volume. Each gather iteration owns one particle, so its stores do not require atomics. It reads a completed grid.

Four ordered stages

    for step in range(steps):
        clear_grid(momentum, mass)
        particle_to_grid(x, v, C, J, momentum, mass, n)
        grid_update(momentum, mass)
        grid_to_particle(x, v, C, J, momentum, n)
        if (step + 1) % (steps // 3 if not check else steps) == 0:
            snapshots.append(x.numpy())

Clearing the grid is essential: its arrays contain temporary contributions for one substep. Those arrays are reused instead of reallocated. Each stage has one parallel loop, with nested stencil loops sequential within an iteration.

Check and explore

uv run python docs/examples/mpm.py --check
uv run --with vtk python docs/examples/mpm.py --output mpm.png

The small check verifies total scattered mass and finite, in-domain positions. Before boundary contact it also checks the expected uniform gravitational acceleration and unchanged horizontal positions. These tests exercise numerical invariants; they are not a full reference validation of long fluid dynamics.

The figure shows steps 0, 300, 600 and 900. Increase stiffness cautiously: explicit time integration needs a correspondingly smaller timestep. Compare early trajectories and conserved mass rather than expecting identical late particle positions after contact on every backend.

Complete program

Download mpm.py. The adapted code is Apache-2.0 licensed.

# Copyright (c) 2026 Kitware, Inc.
# SPDX-License-Identifier: Apache-2.0
"""A falling fluid block using MLS-MPM, adapted from Taichi's mpm88.py.

Copyright (c) The Taichi Authors; original example by Yuanming Hu.
Apache-2.0; see LICENSE-APACHE-2.0.txt.
Source: https://github.com/taichi-dev/taichi/blob/master/python/taichi/examples/simulation/mpm88.py
"""

import argparse

import numpy as np

import tack

GRID = tack.constant(64)
DX = tack.constant(1.0 / GRID)
DT = tack.constant(2e-4)
VOLUME = tack.constant((DX * 0.5) ** 2)
MASS = tack.constant(VOLUME)  # density is one
STIFFNESS = tack.constant(400.0)


@tack.kernel
def clear_grid(momentum, mass):
    for i, j in tack.ndindex(GRID, GRID):
        momentum[i, j] = 0.0
        mass[i, j] = 0.0


@tack.kernel
def particle_to_grid(x, v, C, J, momentum, mass, n):
    for p in range(n):
        xp = x[p] / DX
        base = int(xp - 0.5)
        fx = xp - base
        weights = tack.mat(0.5 * (1.5 - fx) ** 2, 0.75 - (fx - 1.0) ** 2,
                           0.5 * (fx - 0.5) ** 2)
        stress = -DT * 4.0 * STIFFNESS * VOLUME * (J[p] - 1.0) / DX**2
        affine = tack.mat2([stress, 0.0], [0.0, stress]) + MASS * C[p]
        for i, j in tack.ndindex(3, 3):
            offset = tack.vec2(i, j)
            dpos = (offset - fx) * DX
            weight = weights[i, 0] * weights[j, 1]
            tack.atomic_add(momentum, base + offset, weight * (MASS * v[p] + affine @ dpos))
            tack.atomic_add(mass, base + offset, weight * MASS)


@tack.kernel
def grid_update(momentum, mass):
    for i, j in tack.ndindex(GRID, GRID):
        if mass[i, j] > 0:
            momentum[i, j] /= mass[i, j]  # momentum becomes velocity
            momentum[i, j].y -= DT * 9.8
        if i < 3 and momentum[i, j].x < 0:
            momentum[i, j].x = 0
        if i >= GRID - 3 and momentum[i, j].x > 0:
            momentum[i, j].x = 0
        if j < 3 and momentum[i, j].y < 0:
            momentum[i, j].y = 0
        if j >= GRID - 3 and momentum[i, j].y > 0:
            momentum[i, j].y = 0


@tack.kernel
def grid_to_particle(x, v, C, J, grid_v, n):
    for p in range(n):
        xp = x[p] / DX
        base = int(xp - 0.5)
        fx = xp - base
        weights = tack.mat(0.5 * (1.5 - fx) ** 2, 0.75 - (fx - 1.0) ** 2,
                           0.5 * (fx - 0.5) ** 2)
        new_v = tack.vec2(0.0, 0.0)
        new_C = tack.mat2([0.0, 0.0], [0.0, 0.0])
        for i, j in tack.ndindex(3, 3):
            offset = tack.vec2(i, j)
            dpos = (offset - fx) * DX
            weight = weights[i, 0] * weights[j, 1]
            gv = grid_v[base + offset]
            new_v += weight * gv
            new_C += 4.0 * weight * gv.outer_product(dpos) / DX**2
        v[p] = new_v
        x[p] += DT * new_v
        J[p] *= 1.0 + DT * new_C.trace()
        C[p] = new_C


def run(arch="cpu", check=False):
    tack.set_default_device(arch)
    n, steps = (128, 10) if check else (2048, 900)
    initial = (np.random.default_rng(0).random((n, 2)) * 0.3 + [0.35, 0.45]).astype(np.float32)
    x = tack.empty((n,), tack.vec2(tack.f32))
    v = tack.empty((n,), tack.vec2(tack.f32))
    C = tack.empty((n,), tack.mat2(tack.f32))
    J = tack.empty((n,))
    tack.copyto(x, initial)
    v.fill(0.0)       # read by the first scatter, before anything writes them
    C.fill(0.0)
    J.fill(1.0)
    momentum = tack.empty((GRID, GRID), tack.vec2(tack.f32))
    mass = tack.empty((GRID, GRID))
    snapshots = [initial.copy()]
    for step in range(steps):
        clear_grid(momentum, mass)
        particle_to_grid(x, v, C, J, momentum, mass, n)
        grid_update(momentum, mass)
        grid_to_particle(x, v, C, J, momentum, n)
        if (step + 1) % (steps // 3 if not check else steps) == 0:
            snapshots.append(x.numpy())
    if check:
        positions = x.numpy()
        velocity = v.numpy()
        # The weights partition unity, so scatter must conserve particle mass.
        np.testing.assert_allclose(mass.numpy().sum(dtype=np.float64), n * float(MASS), rtol=2e-6)
        assert np.all(np.isfinite(positions)) and np.all((positions > 0) & (positions < 1))
        assert np.all(J.numpy() > 0)
        # Before contact, a uniform gravitational acceleration moves every particle together.
        np.testing.assert_allclose(velocity[:, 1], -9.8 * float(DT) * steps, atol=2e-5)
        np.testing.assert_allclose(positions[:, 0], initial[:, 0], atol=2e-6)
    return snapshots


def save_figure(snapshots, output):
    import vtkmodules.vtkRenderingFreeType
    import vtkmodules.vtkRenderingOpenGL2  # noqa: F401
    from vtkmodules.vtkCommonCore import VTK_TEXT_CENTERED, VTK_TEXT_TOP, vtkPoints
    from vtkmodules.vtkCommonDataModel import vtkPolyData
    from vtkmodules.vtkFiltersGeneral import vtkVertexGlyphFilter
    from vtkmodules.vtkFiltersSources import vtkOutlineSource
    from vtkmodules.vtkIOImage import vtkPNGWriter
    from vtkmodules.vtkRenderingCore import (
        VTK_NORMALIZED_VIEWPORT,
        vtkActor,
        vtkPolyDataMapper,
        vtkRenderer,
        vtkRenderWindow,
        vtkTextActor,
        vtkWindowToImageFilter,
    )

    window = vtkRenderWindow(off_screen_rendering=True, multi_samples=0, size=(1600, 440))
    for index, (xy, step) in enumerate(zip(snapshots, (0, 300, 600, 900), strict=True)):
        particles = vtkPolyData(points=vtkPoints(data=np.column_stack((xy, np.zeros(len(xy))))))
        mapper = vtkPolyDataMapper()
        particles >> vtkVertexGlyphFilter() >> mapper
        actor = vtkActor(mapper=mapper)
        actor.property.color = (0.15, 0.39, 0.92)
        actor.property.point_size = 2
        border_mapper = vtkPolyDataMapper()
        vtkOutlineSource(bounds=(0, 1, 0, 1, 0, 0)) >> border_mapper
        border = vtkActor(mapper=border_mapper)
        border.property.color = (0.2, 0.2, 0.2)
        renderer = vtkRenderer(background=(1, 1, 1), viewport=(index / 4, 0, (index + 1) / 4, 1))
        renderer.AddActor(actor)
        renderer.AddActor(border)
        title = vtkTextActor(input=f"Step {step}", position=(0.5, 0.98))
        title.position_coordinate.coordinate_system = VTK_NORMALIZED_VIEWPORT
        text = title.text_property
        text.justification = VTK_TEXT_CENTERED
        text.vertical_justification = VTK_TEXT_TOP
        text.font_size = 20
        text.color = (0, 0, 0)
        renderer.AddViewProp(title)
        camera = renderer.active_camera
        camera.position = (0.5, 0.5, 1)
        camera.focal_point = (0.5, 0.5, 0)
        camera.parallel_projection = True
        camera.parallel_scale = 0.6
        window.AddRenderer(renderer)
    window.Render()
    capture = vtkWindowToImageFilter(input=window, read_front_buffer=False)
    writer = vtkPNGWriter(file_name=str(output))
    capture >> writer
    writer.Write()  # calling the writer, or updating it, writes raw pixels instead of a PNG
    window.Finalize()


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--arch", default="cpu", choices=["cpu", "metal", "cuda", "hip", "level_zero"])
    parser.add_argument("--check", action="store_true")
    parser.add_argument("--output", help="Save a figure (requires VTK; use without --check)")
    args = parser.parse_args()
    snapshots = run(args.arch, args.check)
    if args.output:
        if args.check:
            parser.error("use --output without --check")
        save_figure(snapshots, args.output)
    print("MPM: check passed" if args.check else "MPM: simulation complete")