A basic (unoptimized, feature-sparse) implementation of Müller’s seminal SPH paper in 2D is provided on GitHub to accompany this tutorial. It is available here.
While the aforementioned writeup covers the mathematics of SPH quite well, it is worth noting once more the equations we will be directly concerned with; that is, the formulation of the incompressible Navier-Stokes equations using SPH
In a word, the density of a particle can be calculated as a weighted average over the surrounding particles; fluid forces, such as pressure and viscosity, can be modeled again summing contributions from neighboring particles, weighted by density and distance.
Keen readers will find one aspect of fluid simulation notably absent from our review discussion: surface tension. While a basic surface tension model is discussed by Müller in his original paper, code simplicity is greatly improved by focusing on modeling directly using SPH formulations.
Further, those with possible prior experience reading SPH code will note a lack of common optimizations, including, but not limited to, a griding system, parallelization, and solver term precomputation. Again, these are deliberate consequences of maintaining clean, readable, and minimal code to best explain the underlying mathematical optimizations. Explorations or tutorials regarding the optimization of SPH are planned for the future. Both griding and precomputation will be briefly touched upon in later sections of this writeup.
At the most basic level, our SPH demo must produce a reasonably realistic real-time simulation of a fluid. To this end, our code will have three principal components: a rendering system, an update system (the SPH solver), and user input handling.
We will initialize our fluid in a dam-break flow configuration, subject to boundary conditions at the edge of our rendering area, and allow the user to add additional drops of fluid.
Our rendering system will have the following methods:
And our interactivity handling:
Each component will be broken down in turn, with further discussion of the implementation details for each core method.
OpenGL is the gold standard for desktop graphics programming. We make use of a very basic OpenGL renderer built upon GLUT, a utilities toolkit which provides a cross-platform windowing system API.
The following initialization method is called by
main, before the rendering loop is entered. This method takes care of a few basic tasks: it sets our window background color to a nice grey, enables point smoothing (anti-aliasing), sets the point size to a multiple of our particle smoothing length, and tells the renderer we are using a view projection matrix (discussed below).
Our render function is slightly more complicated. It is called by the windowing system each time we can redraw the current frame. That is, the effect of a real-time simulation comes from updating the simulation state, then rendering the particles in a new position, as many times per second as possible.
Breaking it down,
glClear gives us a blank image buffer state, upon which we can draw the contents of our new frame.
glOrtho set the coordinate system for drawing to be an orthographic projection in the screen space, mapped to the known constant width and height of the simulation viewing area.
glColor4f sets the color in RGBA for all subsequent draw calls (a nice blue for our fluid particles). Lastly, we loop through our global state array of all fluid particles, placing a vertex at the given x and y coordinate.
glEnd are used to signal the type of drawing we want to be applied to these vertices. We elect to place points using the flag
GL_POINTS, though we could just as well be placing and connecting vertices with lines (
GL_LINES), for example.
While we will not further discuss the usage of OpenGL, this type of rendering pattern is very common in simple orthographic view applications, and could easily be extended to myriad applications.
As a reminder, our solver will have the following methods:
The structure can best be seen by examining the
Update method first, called as frequently as possible per second between frame rendering to step our simulation state forward in time. Predictably, we follow the SPH framework of first computing densities, which we then use to compute pressure at particle locations. Density and pressure together allow us to calculate the forces acting on each particle, which are in turn used by
Integrate to calculate acceleration and update final particle positions for rendering.
The following data structure will track relevant quantities for all of the particles in our system: position
rho, and pressure
We use the fantastic
Vector2d provided by the Eigen library to simply our modest use of linear algebra, such as Euclidean distances.
In order to create a visually interesting simulation, we must initialize our particle system in a state that evolves interestingly in forward time. For this reason, we prefer to replicate a common setup in fluid dynamics, the so-called ‘dam break’, a column of fluid mass splashing down into a container of fixed size.
We achieve a ‘dam break’ configuration with a very simple loop, placing particles with a spacing corresponding to the smoothing length (kernel radius) of the SPH solver. The size of the dam scales as a proportion of the chosen simulation extents. A small jitter is applied to give more degenerate scattering upon contact with the floor boundary.
The first step of our SPH solver is computing densities and pressures of the fluid at new particle positions. For all particles , we loop over other particles , summing their density contributions, , subject to the application of Müller’s Poly6 kernel function. Particles beyond the smoothing radius of support, , do not contribute to the summed density. The ideal gas law, , is chosen as an equation of state, relating pressure to the summed density.
Similarly, for all particles , we loop over other particles , summing their contributions to pressure and viscosity forces. Note that a simple optimization is used to precompute the constant scalar component of the gradient of the Spiky kernel and the Laplacian of the Viscosity kernel. The force of gravity is calculated from a gravitational acceleration constant
G and .
Whilst the equations of fluid motion and the numerical approximation approach might be new, hopefully our chosen numerical integration scheme will not be.
As SPH gives us an approximation of density and the force acting on each particle for each timestep, we can use a standard numerical integration scheme to solve for velocity, allowing us to update particle positions in forward time. At the risk of an overly verbose explanation, with known quantities of density and force, we obtain particle acceleration
We then trivially apply Euler’s method, a first-order forward numerical integration scheme
giving our final update equation for particle positions. Our
Integrate method applies the same to all particles:
We enforce the simulation bounds by means of a simple set of boundary conditions: if a particle’s position is greater than the simulation bounds (defined as
VIEW_HEIGHT), we dampen its velocity by some factor and move it back within bounds. Note that
BOUND_DAMPING such that the applied velocity impulse reflects particles away from the boundary.
This unoptimized simulation loops over all neighboring particles for each particle of interest in the density and force steps; it has a solver complexity of . Thus, scaling to large numbers of particles is untenable, even with parallelization on modern CPUs. To solve this problem, many SPH implementations exploit the fact that particles beyond the kernel radius of support, , do not contribute to calculations. With this in mind, a spatial grid can be constructed with cell size , meaning that any neighboring particle must exist within the directly adjacent 8 cells. Pre-sorting particles into a spatial grid for fast neighborhood iteration allows for large speedups versus unoptimized nested loops.
Due to the use of the ideal gas equation one can see that the pressure acting on a particle can quickly induce a large force with relatively little divergence from the rest density , if the pressure constant is sufficiently large.
Further, without going into too much detail about numerical stability, one can understand fundamentally that Euler integration could exacerbate such impulses—with a large timestep, the changes in velocity and position in one frame could be well beyond the reasonable extents of the simulation, leading to equally large corrective forces the next frame, and so on.
For these reasons, our simulation is highly sensitive to initial parameterization, especially the gas constant, enforcing stiffness/incompressibility, and the rest density, affecting the magnitude of pressure forces. The timestep size, greatly affects the overall stability of the numerical integration and, in some rough sense, the likelihood of devolution into a chaotic mess. As an exercise to the reader, I encourage exploring the sensitivity to parameters by trying different values for these constants. I also encourage implementing an implicit integrator, such as the leapfrog method, which is stable for oscillatory systems. Forward Euler is generally not stable for these kinds of systems.
As a final note, the constant terms of the kernel functions (and their derivatives) that are used in our SPH formulation are precomputed for speed. These follow directly from analytically working out the gradient and laplacian.
As previously mentioned, we make use of GLUT, a cross-platform windowing toolkit, to facilitate user interaction. The user can take two simple actions by means of keypress: adding small blocks of fluid to drop and resetting the simulation. These actions are mapped to the
r keys, respectively. In the case of adding fluid, we use a technique similar to the dam break initialization, this time with a smaller block and closer spacing, giving a satisfying expansion and fall down into the container. A global counter is used to limit the number of particles.
Finally, we arrive at the
main method, which puts all of the pieces together. First, a window is initialized with the desired width, height, and title.
glutDisplayFunc sets our
Render function to be used to generate a new frame as often as possible. In the time between rendering, the
glutIdleFunc will call
Update, moving the simulation forward in time.
InitGL sets up our rendering environment, followed by
InitSPH, which populates our simulation with particles in a dam break configuration. Finally, entering the
glutMainLoop begins calling the render and update functions, displaying the window and blocking until it is closed by the user or the program is killed.
Please see the final section of my previous tutorial for a discussion of further reading into more modern Smoothed Particle Hydrodynamics solvers. To summarize the limitations of this demonstration, surface tension has not been implemented, the lack of a gridding system severely impacts performance, no attempt was made to parallelize any computation, the ideal gas equation causes incompressibility to be poorly enforced, and the numerical integration scheme leads to parameter sensitivity and instability. With that said, I hope that this has been a useful gentle introduction to the mathematics and code of implementing real-time fluid simulations. As always, please reach out with any comments or corrections.
— Lucas Schuermann