D3Q19 LBM Stream and Collide in GLSL
This article provides an overview of implementing the stream-and-collide algorithm for the D3Q19 Lattice Boltzmann Method (LBM) in OpenGL Shading Language (GLSL). It explains memory organization using 3D textures or buffers, the GPU-friendly "pull" streaming convention, the reduction of populations into macroscopic density and velocity, and the local relaxation toward equilibrium using the Bhatnagar-Gross-Krook (BGK) collision operator.
Memory Representation and Double Buffering
The D3Q19 lattice model tracks 19 discrete particle velocity
distributions (\(f_0\) through \(f_{18}\)) per grid node. In GLSL, this data
is typically mapped across five RGBA32F 3D textures
(providing 20 channels, leaving one unused) or stored as a flat array
within a Shader Storage Buffer Object (SSBO) inside a compute
shader.
To prevent read-write race conditions across adjacent threads, a double-buffering (ping-pong) scheme is required. The current time step reads exclusively from the read texture/buffer \(t\), and writes newly computed values to the write texture/buffer \(t + \Delta t\).
The "Pull" Streaming Scheme
While theoretical LBM describes streaming as "pushing" distributions to adjacent nodes, GPU parallelization requires a "pull" scheme. Each thread corresponds to a target lattice cell at coordinate \(\vec{x}\) and fetches the incoming populations that were directed toward it from neighboring cells at \(\vec{x} - \vec{c}_i\), where \(\vec{c}_i\) is the discrete lattice velocity vector:
- Rest velocity (1 vector): \(\vec{c}_0 = (0, 0, 0)\)
- Face neighbors (6 vectors): \(\vec{c}_{1..6} = (\pm 1, 0, 0), (0, \pm 1, 0), (0, 0, \pm 1)\)
- Edge neighbors (12 vectors): \(\vec{c}_{7..18} = (\pm 1, \pm 1, 0), (\pm 1, 0, \pm 1), (0, \pm 1, \pm 1)\)
By sampling the neighbor coordinate \(\vec{x} - \vec{c}_i\) from the input state, streaming is performed implicitly during the read phase.
Calculating Macroscopic Moments
After pulling the 19 distribution values \(f_i\), the local fluid density \(\rho\) and macroscopic velocity \(\vec{u}\) are derived via summation:
\[\rho = \sum_{i=0}^{18} f_i\]
\[\vec{u} = \frac{1}{\rho} \sum_{i=0}^{18} f_i \vec{c}_i\]
In GLSL, this is carried out using vector dot products and additions over the cached local variables.
The BGK Collision Step
The standard single-relaxation-time Bhatnagar-Gross-Krook (BGK) model relaxes the distribution functions toward an equilibrium state \(f_i^{eq}\). The equilibrium distribution is defined as:
\[f_i^{eq} = w_i \rho \left( 1 + \frac{\vec{c}_i \cdot \vec{u}}{c_s^2} + \frac{(\vec{c}_i \cdot \vec{u})^2}{2 c_s^4} - \frac{\vec{u} \cdot \vec{u}}{2 c_s^2} \right)\]
For the D3Q19 lattice, the speed of sound constant is \(c_s^2 = \frac{1}{3}\), making \(c_s^4 = \frac{1}{9}\). The directional lattice weights \(w_i\) are:
- \(w_0 = \frac{1}{3}\) (rest particle)
- \(w_{1..6} = \frac{1}{18}\) (face connections, \(|\vec{c}_i|^2 = 1\))
- \(w_{7..18} = \frac{1}{36}\) (edge connections, \(|\vec{c}_i|^2 = 2\))
The post-collision value \(f_i^*\) to be saved into the write buffer is calculated by relaxing \(f_i\) by a relaxation parameter \(\tau\):
\[f_i^* = f_i - \frac{1}{\tau} (f_i - f_i^{eq})\]
Here, \(\tau\) directly dictates the fluid's kinematic shear viscosity \(\nu = c_s^2 (\tau - 0.5) \Delta t\).
GLSL Implementation Routine
In a GLSL compute shader, the combined step is structured as follows:
#version 450
layout(local_size_x = 8, local_size_y = 8, local_size_z = 8) in;
// 19 velocity vectors and weights defined as constants
const ivec3 c[19] = ivec3[19](...);
const float w[19] = float[19](...);
layout(binding = 0) uniform sampler3D u_PopulationsIn[5];
layout(binding = 1, rgba32f) uniform writeonly image3D u_PopulationsOut[5];
uniform float u_Tau;
void main() {
ivec3 pos = ivec3(gl_GlobalInvocationID.xyz);
float f[19];
float rho = 0.0;
vec3 momentum = vec3(0.0);
// 1. Pull Streaming & Moment Accumulation
for (int i = 0; i < 19; ++i) {
ivec3 sourcePos = pos - c[i];
// Unpack f[i] from the appropriate texture and channel
f[i] = fetchPopulation(u_PopulationsIn, sourcePos, i);
rho += f[i];
momentum += f[i] * vec3(c[i]);
}
vec3 u = momentum / rho;
float u_sq = dot(u, u);
// 2. Collision & Write-back
for (int i = 0; i < 19; ++i) {
vec3 ci = vec3(c[i]);
float ci_u = dot(ci, u);
// Equilibrium computation (cs^2 = 1/3)
float feq = w[i] * rho * (1.0 + 3.0 * ci_u + 4.5 * (ci_u * ci_u) - 1.5 * u_sq);
// BGK Relaxation
float f_star = f[i] - (f[i] - feq) / u_Tau;
// Store result to output texture bindings
storePopulation(u_PopulationsOut, pos, i, f_star);
}
}Handling boundary conditions, such as standard half-way bounce-back for no-slip solid obstacles, involves checking a voxel flag before collision. If a node is tagged as solid, the incoming streamed populations are reflected along their inverted direction vectors (\(\vec{c}_j = -\vec{c}_i\)) and written directly back into the output buffers without applying the BGK relaxation.