Welcome to the zone of technical details! Here we'll walk through the implementation of the simulation from the ground up.
Tip
Think something can be explained better? Open a pull request! :)
Important
Prerequisites Vector calculus, transformation matrices
We'll start with a simple particle simulation. We'll spawn a certain number of particles making up our material. They each have their own position
struct Uniforms {
simulation_timestep: f32,
}
struct ParticleData {
position: vec3f,
velocity: vec3f,
mass: f32,
}Every timestep (of a fixed duration
Note
Lowercase
Using Newton's second law of motion, we can obtain the acceleration on each particle in meters per second per second
We now have the rate of change of position (velocity) as well as the rate of change of velocity (acceleration). To update our particles' states, we'll use Euler integration, or simply multiplying the rates of change by our timestep
If we do a timestep or a few every frame, our particles should now fall downward!
Now our particles are all moving independently of each other, so they're all acting like separate objects rather than a continuous material.
Material point method (MPM) is a technique that will help us model the cohesion of our material. With MPM, we'll treat each particle as a material point, or a sample of how our imagined continuous, deformable material is moving or deformed at a certain point in space.
MPM will also introduce a grid that we'll modify with our forces, rather than the particles directly. Notably, grid cells are built to not have gaps in between them... which makes a grid strategy fitting for a continuous material! The main drivers of the simulation are still the particles, though, which gives us the flexibility to move around freely without being constrained by the grid.
We can divide the main MPM algorithm into 4 steps:
-
Particle-to-grid (P2G). Transfer the momentum
$\mathbf p = m\mathbf v$ and mass of each particle to grid cells that are near the particle. - Grid update. Update each grid cell's momentum and mass using our external forces.
- Grid-to-particle (G2P). Transfer the momentum of each grid cell back to the particles in its vicinity.
- Grid clear. Zero out all the grid cell momentums and masses, so the next frame can do this process over again.
Before we jump in, let's construct our MPM grid first.
struct GridUniforms {
grid_min_coords: vec3f, // bottom-left-front corner of the grid
grid_cell_dims: vec3f, // length/width/height of each cell
n_cells_per_grid_axis: vec3u, // number of cells in each axis
fixed_point_scale: f32, // fixed-point scale factor
}
struct CellData {
momentum_x: atomic<i32>,
momentum_y: atomic<i32>,
momentum_z: atomic<i32>,
mass: atomic<i32>,
}Note the use of atomics here as well as the inclusion of a fixed-point scale factor. We'll come back to those after describing the particle-to-grid step.
Tip
We started seeing some nice-looking snow breakage effects at a grid cell resolution of 128 in a 4-meter-large grid and 50 000+ particles! If your grid resolution is too low, your snow will move more slowly and deform more like soap bubbles.
First, we need to transfer the momentum and mass of each particle to grid cells that are near the particle. To do this transfer, we'll weight the particle's influence on those grid cells based on the distance of the cell's center from the particle.
We'll need to calculate which grid cell
For the weights themselves, we'll use a computationally cheap piecewise function for the weights, a quadratic B-spline based on the particle's fractional position in the cell.
To do this, we'll want to take the 27 nearest cells to the particle (or the 3 nearest 2D planes of cells in each axis). Let's then calculate the amount of influence
Tip
For example, in the diagram above,
(All planes farther than 1 plane away—that is,
For a given plane of cells, these formulae result in the following weights based on the particle's distance from that plane's center in each axis:
This should give us 3 weight values
Finally, we can calculate the amount of momentum and mass to transfer to each grid cell:
Sum up these values for every particle, and we'll be ready to apply forces!
Since we're working with the GPU here, we'll handle one particle per thread in a compute shader. The grid and particle data is handled in storage memory, shared between all threads. However, we'll run into a problem if we run this algorithm as written: we'll need to read from and write to the same grid cells from many different threads, resulting in a race condition.
Recall in our GridCell struct we used atomic<i32>s to store the momentum and mass. Atomics ensure that only one thread will read, write, or read-and-write to a memory location at a time. Since we're accumulating mass and momentum here, we'll use the atomicAdd function to read-and-write to the grid cell without running into conflicts.
Note that WebGPU devices won't allow floating-point-type atomics like atomic<f32> unless it is requested and it is available on the device. To handle this, we'll use fixed-point arithmetic, where we multiply our floats by a constant factor, like i32 and passing it to the atomic function.
All in all, our algorithm looks something like:
let particle = &particle_data[thread_index];
let mass = (*particle).mass;
let momentum = (*particle).vel * mass;
let pos = (*particle).pos;
let cell_number = vec3i((pos - grid_uniforms.grid_min_coords) / grid_uniforms.grid_cell_dims);
let fractional_pos = pos - grid_uniforms.grid_min_coords - vec3f(cell_number) * grid_uniforms.grid_cell_dims;
for (var offset_z = -1i; offset_z <= 1i; offset_z++) {
for (var offset_y = -1i; offset_y <= 1i; offset_y++) {
for (var offset_x = -1i; offset_x <= 1i; offset_x++) {
let cell_index = linearizeCellIndex(cell_number + vec3i(offset_x, offset_y, offset_z));
let cell = &grid_data[cell_index];
let weights = calculateQuadraticBSplineCellWeights(fractional_pos); // see above
let weight = weights[u32(offset_x + 1)].x
* weights[u32(offset_y + 1)].y
* weights[u32(offset_z + 1)].z;
let momentum_contribution = momentum * weight * grid_uniforms.fixed_point_scale;
let mass_contribution = mass * weight * grid_uniforms.fixed_point_scale;
atomicAdd(&(*cell).momentum_x, i32(momentum_contribution.x));
atomicAdd(&(*cell).momentum_y, i32(momentum_contribution.y));
atomicAdd(&(*cell).momentum_z, i32(momentum_contribution.z));
atomicAdd(&(*cell).mass, i32(mass_contribution));
}
}
}In this step, we'll apply forces to the grid cells based on their stored momentum and mass. If we already know what those forces
And that's it!
let cell = &cell_data[thread_index];
let mass = atomicLoad(&(*cell).mass) / grid_uniforms.fixed_point_scale;
let force = vec3f(0, 0, -9.81) * mass;
let momentum_contrib = force * uniforms.simulation_timestep * grid_uniforms.fixed_point_scale;
atomicAdd(&(*cell).momentum_x, i32(momentum_contrib.x));
atomicAdd(&(*cell).momentum_y, i32(momentum_contrib.y));
atomicAdd(&(*cell).momentum_z, i32(momentum_contrib.z));In this step, we're going to accumulate the new momentums onto the particles based on the grid cells, and then we'll update the particle's position based on that momentum.
To do this, we're going to use our B-spline weights from before as well as the grid loop to determine the influence each grid cell now has on the current particle. After we've added up the velocity contributions from all the grid cells, we can then update the position.
let particle = &particle_data[thread_index];
let mass = (*particle).mass;
let momentum = (*particle).vel * mass;
let pos = (*particle).pos;
let pos_from_grid_min = pos - grid_uniforms.grid_min_coords;
let cell_number = vec3i(pos_from_grid_min / grid_uniforms.grid_cell_dims);
let fractional_pos = pos_from_grid_min - vec3f(cell_number) * grid_uniforms.grid_cell_dims;
let weights = calculateQuadraticBSplineCellWeights(fractional_pos); // see above
var new_particle_velocity = vec3f(0);
for (var offset_z = -1i; offset_z <= 1i; offset_z++) {
for (var offset_y = -1i; offset_y <= 1i; offset_y++) {
for (var offset_x = -1i; offset_x <= 1i; offset_x++) {
let cell_index = linearizeCellIndex(cell_number + vec3i(offset_x, offset_y, offset_z));
let cell = &grid_data[cell_index];
let weight = weights[u32(offset_x + 1)].x
* weights[u32(offset_y + 1)].y
* weights[u32(offset_z + 1)].z;
let grid_momentum = vec3f(
atomicLoad(&(*cell).momentum_x),
atomicLoad(&(*cell).momentum_y),
atomicLoad(&(*cell).momentum_z),
) / grid_uniforms.fixed_point_scale;
let grid_mass = atomicLoad(&(*cell).mass);
new_particle_velocity += grid_momentum / grid_mass * weight;
}
}
}
(*particle).vel = new_particle_velocity;
(*particle).pos += (*particle).vel * uniforms.simulation_timestep;If we'd like, we can also add some boundary conditions here to handle any particles that end up leaving the grid.
Since we add together all the momentums and masses together in the particle-to-grid step, we need to zero out the grid cells before we start accumulating them again for the next timestep. We'll just use some simple atomicStore calls:
let cell = &cell_data[thread_index];
atomicStore(&(*cell).momentum_x, 0);
atomicStore(&(*cell).momentum_y, 0);
atomicStore(&(*cell).momentum_z, 0);
atomicStore(&(*cell).mass, 0);After running the 4 steps repeatedly in a simulation loop, our MPM implementation is complete! Notably, if you add forces other than gravity or set the initial velocities in different directions, you can start to notice the particles bunching up. The velocities of particles will influence nearby particles, making the material look more cohesive, with a goopy or stringy look.
At this point, though, we've only modeled external forces. In fact, if we only have gravity, then we'll just see all the particles fall straght down through the material, which isn't very interesting and doesn't really model any material all that well. We can make things more interesting!
Recall how our material points represent samples of how a continuous material is moving or deformed at each of our particles' positions. It turns out that deformation is the key to adding internal forces!
The deformation matrix
Note
It'll be helpful here to know how matrices represent linear transformations! In the diagrams below, we're going to draw the deformation matrix as how it transforms the basis vectors of a coordinate system.
Deformation is a property of our particles, so let's update our ParticleData struct:
struct ParticleData {
pos: vec3f;
vel: vec3f;
mass: f32;
deformation: mat3x3f; // !! NEW
}When initializing our particles, we'll want to set this to the identity matrix
Suppose we have an initial state of a continuous material at rest. Let's start out by "baking" or saving, into each point of the material, the current world position of that point as a reference. We'll call this saved reference position
This way, we can keep track of how much any given point in the material has moved at some time later in our simulation. With a continuous field of points, we can even tell at any point in space how much a material has separated, sheared, or squished by looking at nearby points!
Our tool of choice for this is the derivative. In particular, we'll want to differentiate the saved positions
Now, our simulated material isn't exactly continuous (so we can't differentiate it), and it'd be nice to be able to access the deformation without having to do a bunch of expensive sampling (you'd have to loop through a lot of particles to find the closest ones!). So, similar to position, we'll track deformation progressively by accumulating changes in deformation over time,
Looks like this is a second-order partial derivative:
Now, that looks an awfully lot like we can just say
The first thing we'll do is modify the grid-to-particle step to calculate the current change in deformation
We can tell that the velocity field above results in a shearing effect, where the area above the particle is moving rightward and the area below is moving leftward. To get our desired change in deformation, we can write the particle's basis vectors for the diagram on the right side, and then take their difference with the basis vectors on the left side:
Also note that
And one more example, this time with compression instead of shearing:
We can probably intuit now that this change in deformation is somehow dependent on how the material's velocity vector varies with respect to position along each axis. In calculus terms, we might say we want to take the derivative of the material's velocity field
...which, for both of the examples above, gives us exactly the values we had written for
One major assumption we made above is that we're starting from the identity matrix as our deformation. We'll get different results with a different starting matrix. In general, we'll want to locally transform the change in deformation above by our particle's existing deformation:
One problem, though: we don't exactly have a continuous velocity field to differentiate, but a set of discrete velocities at the center of each grid cell. We need some way to interpolate those grid cell velocities into a continuous field.
Let's return to our 3D simulation and try writing out the velocity field
So, let's take another look at how we calculated each particle's velocity in the grid-to-particle step to get some more insight:
let weight = weights[u32(offset_x + 1)].x
* weights[u32(offset_y + 1)].y
* weights[u32(offset_z + 1)].z;
// ...
new_particle_velocity += grid_velocity * weight; // grid_velocity = grid_momentum / grid_massThink about what's going on here: given a specific configuration of grid momentums and masses, a particle's velocity is going to be entirely determined by where it is in the grid. That means the grid_velocity * weight (
The B-spline functions we used to compute the weights, it turns out, additionally has the purpose of interpolating our velocities here in a smooth way. This makes our function easy to differentiate with respect to position, and we can even get cheap, exact values for the derivatives since the functions are all simple quadratics.
Our B-spline functions were piecewise, so their derivatives are also piecewise. Let's differentiate the 3 pieces analytically with respect to the fractional position in each axis
Just as we selected which weight
Now back to the overall weight for a cell. After we multiplied the three weights
It'll help us here to consider the overall weight in terms of the 3 individual weight factors in each axis
...where
At last, we now have all the pieces we need to calculate the new deformation gradient, again doing some more Euler-integration:
let weights = calculateQuadraticBSplineCellWeights(fractional_pos); // see above
let weight_derivs = calculateQuadraticBSplineCellWeightDerivatives(fractional_pos); // see above
var total_velocity_gradient = mat3x3f();
for (var offset_z = -1i; offset_z <= 1i; offset_z++) {
for (var offset_y = -1i; offset_y <= 1i; offset_y++) {
for (var offset_x = -1i; offset_x <= 1i; offset_x++) {
let cell_index = linearizeCellIndex(cell_number + vec3i(offset_x, offset_y, offset_z));
let cell = &grid_data[cell_index];
let weight = weights[u32(offset_x + 1)].x
* weights[u32(offset_y + 1)].y
* weights[u32(offset_z + 1)].z;
// ...
let grid_velocity = grid_momentum / grid_mass;
let weight_gradient_contrib = vec3f(
weight_derivs[u32(offset_x + 1)].x * weight[u32(offset_y + 1)].y * weight[u32(offset_z + 1)].z,
weight[u32(offset_x + 1)].x * weight_derivs[u32(offset_y + 1)].y * weight[u32(offset_z + 1)].z,
weight[u32(offset_x + 1)].x * weight[u32(offset_y + 1)].y * weight_derivs[u32(offset_z + 1)].z,
);
total_velocity_gradient += mat3x3f(
weight_gradient_contrib.x * grid_velocity,
weight_gradient_contrib.y * grid_velocity,
weight_gradient_contrib.z * grid_velocity,
);
}
}
}
(*particle).deformation += (IDENTITY_MAT3 + total_velocity_gradient * uniforms.simulation_timestep) * (*particle).deformation;Even after all that, though, our deformation isn't actually doing anything to the simulation. Let's visualize the fruits of our labor by adding a force that comes as a direct result of deformation.
We noted before that all of our particles, with only a gravity force, are going to sink through the material, all the way to the bottom of the grid. The stress force will counteract this by attempting to restore the material's deformation to the original, undeformed state.
Note
TBD
Note
TBD
Note
TBD
Note
TBD
Note
TBD
Note
TBD





