1

I am using Electron with TypeScript to prototype some fluid simulation code, using the Lattice Boltzmann algorithm, which will eventually go into a game. So far, I have been using static boundary conditions (with simulation calculations only occurring on the interior of the grid, and values for the boundary cells remaining fixed), an everything appears to work fine in that regime. In particular, I can impose internal boundary conditions (for example, enforcing that a certain density of fluid always exits a certain lattice site on every frame, to simulate a hose/rocket nozzle/whatever) by just manually setting the cell values in between each simulation step.

However, if I switch to using periodic boundary conditions (i.e., a wrap-around, toroidal-topology Asteroids world), the whole simulation become static. I just get constant fluid density everywhere, for all time, and it's like all of my boundary conditions are erased, no matter where in the simulation cycle (before streaming or before collision) I choose to assert them. I am not sure if periodic boundary conditions will end up being relevant for the game, but this failure makes me think there must be some subtle bug somewhere in the simulation.

The complete code is available at https://github.com/gliese1337/balloon-prototype/tree/deopt , but what I expect are the relevant portions are as follows:

    class LatticeBoltzmann {        
      private streamed: Float32Array; // microscopic densities along each lattice direction
      private collided: Float32Array;
      public rho: Float32Array;    // macroscopic density; cached for rendering

      ...

      public stream(barriers: boolean[]) {
        const { xdim, ydim, collided, streamed } = this;
        const index = (x: number, y: number) => (x%xdim)+(y%ydim)*xdim;
        const cIndex = (x: number, y: number, s: -1|1, j: number) =>
                          9*(((x+s*cxs[j])%xdim)+((y+s*cys[j])%ydim)*xdim)+j;

        // Move particles along their directions of motion:
        for (let y=1; y<ydim-1; y++) {
          for (let x=1; x<xdim-1; x++) {
            const i = index(x, y);
            const i9 = i*9;
            for (let j=0;j<9;j++) {
              streamed[i9 + j] = collided[cIndex(x, y, -1, j)];
            }
          }
        }

        // Handle bounce-back from barriers
        for (let y=0; y<ydim; y++) {
          for (let x=0; x<xdim; x++) {
            const i = index(x, y);
            const i9 = i*9;
            if (barriers[i]) {
              for (let j=1;j<9;j++) {
                streamed[cIndex(x, y, 1, j)] = collided[i9 + opp[j]];
              }
            }
          }
        }
      }

      // Set all densities in a cell to their equilibrium values for a given velocity and density:
      public setEquilibrium(x: number, y: number, ux: number, uy: number, rho: number) {
        const { xdim, streamed } = this;
        const i = x + y*xdim;
        this.rho[i] = rho;

        const i9 = i*9;
        const u2 =  1 - 1.5 * (ux * ux + uy * uy);
        for (let j = 0; j < 9; j++) {
          const dir = cxs[j]*ux + cys[j]*uy;
          streamed[i9+j] =  weights[j] * rho * (u2 + 3 * dir + 4.5 * dir * dir);
        }
      }
    }

Lattice data is stored in two flat arrays, collided which holds the end states after the collision step and serves as input to the streaming step, and streamed, which holds the end states after the streaming step and serves as input to the next collision step. The 9 vector components for the D2Q9 lattice are stored in contiguous blocks, which are then grouped into rows. Note that I am already using mod operations to calculate array indices from lattice coordinates; this is completely irrelevant as long as the simulation calculations only range over the interior of the lattice, but it should make periodic boundaries ready-to-go as soon as the for (let y=1; y<ydim-1; y++) and for (let x=1; x<xdim-1; x++) loops have their bounds changed to for (let y=0; y<ydim; y++) and for (let x=0; x<xdim; x++), respectively. And indeed it is that specific 6-character change that I am having trouble with.

The setEquilibrium method is used to impose boundary conditions. In the driver code, it is currently being called like this, once per frame:

    // Make fluid flow in from the left edge and out through the right edge
    function setBoundaries(LB: LatticeBoltzmann, ux: number) {
        for (let y=0; y<ydim; y++) {
            LB.setEquilibrium(0, y, ux, 0, 1);
            LB.setEquilibrium(xdim-1, y, ux, 0, 1);
        }
    }

With static boundary conditions, calling that once per frame happens to be superfluous, because it only alters the boundary lattice sites. Shifting the hard-coded x-values to the interior of the lattice, however, (where reasserting the boundary conditions once per frame is in fact necessary) does exactly what you would expect--it makes fluid appear or disappear at specific locations. Switching to periodic boundary conditions, however, results in that code ceasing to have any visible effect.

So... anybody know what I might be doing wrong?

Logan R. Kearsley
  • 682
  • 1
  • 5
  • 19

1 Answers1

0

I am not entirely certain why this particular error had this particular weird effect, but it turns out that the problem was in my use of the % operator--it's signed. Thus, when putting in a negative lattice index, naive usage of the % does not perform the wrap-around that one would want from a proper modulus operator; rather, it just gives you back the same negative value, and results in an out-of-bounds array access.

Adding on the array dimension prior to taking the remainder ensures that all values are positive, and we get the necessary wrap-around behavior.

Incidentally, being able to range over the entire lattice without bothering to treat the edges specially allows for collapsing nested loops into a single linear scan over the entire lattice, which eliminates the need for the primary index calculation function, and enormously simplifies the collision-streaming offset index function, cIndex, which now looks like const cIndex = (i: number, s: -1|1, j: number) => 9*((i+s*(cxs[j]+cys[j]*xdim)+max)%max)+j;, requiring only a single modulus instead of one per dimension. The result of that string of simplifications is a massive speedup to the code, with associated improved framerate.

Logan R. Kearsley
  • 682
  • 1
  • 5
  • 19