CUDA × OpenGL:格子ボルツマン法(LBM)によるリアルタイム流体シミュレーターの構築

GPUの演算能力を、物理シミュレーションとグラフィックスの融合に活用してみました。 本プロジェクトでは、流体解析の手法である「格子ボルツマン法(LBM: Lattice Boltzmann Method)」をCUDAで実装し、OpenGLを用いてその動態をリアルタイムに可視化するシミュレーターを作成しました。

中心には複雑な干渉を生む「星型」の障害物を配置。マウスドラッグによる滑らかな波紋の生成や、障害物による波の回折・干渉を、512×512のグリッド上で高フレームレートにシミュレートします。物理演算と描画の両方をGPU内で完結させることで、CPUでは到底不可能なリアルタイム性を実現しています。


技術詳細解説

本プログラムは、大きく分けて**「物理演算(LBM)」「パルス注入(入力)」「可視化」**の3つのフェーズで構成されています。

1. 格子ボルツマン法 (D2Q9モデル)

流体を微視的な粒子の集まりとして捉え、その速度分布関数の変化を計算します。

  • D2Q9モデル: 各格子点は9つの速度方向(静止、上下左右、斜め)を持ちます。

  • ストリーミング(移動): 隣接する格子から粒子が移動してくるステップです。本コードでは、境界条件(星型の壁)に衝突した粒子を跳ね返す「Bounce-back法」を実装しています。

  • 衝突(緩和): 粒子同士の衝突により、局所的な平衡状態へ近づくステップです。定数 omega(緩和時間)が流体の粘性を決定します。

2. 滑らかなマウス入力(線形補間パルス)

マウスドラッグの軌跡を滑らかに表現するため、単なる点ではなく「線分」としてエネルギーを注入しています。

  • 線分最短距離計算: 前フレームと現フレームの座標を結ぶ線分から、各格子点への最短距離を計算します。

  • ガウス分布による注入: 距離に応じた重み付け(expf)を行い、密度を加算することで、航跡のような自然な波紋を生成します。

3. GPUダイレクト可視化

演算結果を一度もCPUに戻すことなく、GPU内で直接映像へ変換しています。

  • 物理量のカラーマッピング: 格子点ごとの流速(ux, uy)と密度(rho)の変化を色相に変換。tanhf 関数を用いることで、激しい変化もサチュレーションを起こさず美しく描写します。

  • テクスチャ共有: CUDAで計算した画像バッファをOpenGLのテクスチャとしてバインドし、1枚の板ポリゴンに貼り付けて表示しています。

    コンパイルコマンド
    nvcc lbm_cuda_opengl.cu -o lbm_sim -arch=sm_61 -lGLEW -lGLU -lGL -lglut -lm
    
    #include <GL/glew.h>
    #include <GL/freeglut.h>
    #include <cuda_runtime.h>
    #include <vector>
    #include <math.h>
    #include <stdio.h>
    
    const int WIDTH = 512, HEIGHT = 512, Q = 9;
    int win_w = WIDTH, win_h = HEIGHT;
    const float omega = 0.7f;
    
    bool is_dragging = false;
    int prev_mx = -1, prev_my = -1, cur_mx = -1, cur_my = -1;
    
    float *d_f1, *d_f2, *d_ux, *d_uy, *d_rho;
    int *d_boundary;
    uchar4 *d_img;
    std::vector<uchar4> h_output_buffer(WIDTH * HEIGHT);
    GLuint tex;
    
    __device__ const int d_cx[9] = {0, 1, 0, -1, 0, 1, -1, -1, 1};
    __device__ const int d_cy[9] = {0, 0, 1, 0, -1, 1, 1, -1, -1};
    __device__ const float d_w[9] = {4/9.f, 1/9.f, 1/9.f, 1/9.f, 1/9.f, 1/36.f, 1/36.f, 1/36.f, 1/36.f};
    const float h_w[9] = {4/9.f, 1/9.f, 1/9.f, 1/9.f, 1/9.f, 1/36.f, 1/36.f, 1/36.f, 1/36.f};
    
    // --- パルス生成:線形補間 ---
    __global__ void pulse_line_kernel(float *f, int x1, int y1, int x2, int y2, float mag) {
        int x = blockIdx.x * blockDim.x + threadIdx.x, y = blockIdx.y * blockDim.y + threadIdx.y;
        if (x >= WIDTH || y >= HEIGHT) return;
        float dx = (float)(x2 - x1), dy = (float)(y2 - y1);
        float t = fmaxf(0.0f, fminf(1.0f, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy + 1e-8f)));
        float px = x1 + t * dx, py = y1 + t * dy;
        float dist2 = (x - px) * (x - px) + (y - py) * (y - py);
        float weight = expf(-dist2 / 200.0f);
        if (weight > 0.01f) {
            for (int q = 0; q < Q; q++) f[q * WIDTH * HEIGHT + y * WIDTH + x] += d_w[q] * weight * mag;
        }
    }
    
    // --- LBM演算 ---
    __global__ void lbm_kernel(float *fin, float *fout, float *rho, float *ux, float *uy, const int *bound) {
        int x = blockIdx.x * blockDim.x + threadIdx.x, y = blockIdx.y * blockDim.y + threadIdx.y;
        if (x >= WIDTH || y >= HEIGHT) return;
        int idx = y * WIDTH + x;
        if (bound[idx]) return;
    
        float f[9], r = 0, vx = 0, vy = 0;
        int opp[9] = {0, 3, 4, 1, 2, 7, 8, 5, 6};
        for (int q = 0; q < Q; q++) {
            int sx = (x - d_cx[q] + WIDTH) % WIDTH, sy = (y - d_cy[q] + HEIGHT) % HEIGHT;
            f[q] = bound[sy * WIDTH + sx] ? fin[opp[q] * WIDTH * HEIGHT + idx] : fin[q * WIDTH * HEIGHT + sy * WIDTH + sx];
            r += f[q]; vx += d_cx[q] * f[q]; vy += d_cy[q] * f[q];
        }
        rho[idx] = r; ux[idx] = vx / (r + 1e-8f); uy[idx] = vy / (r + 1e-8f);
        for (int q = 0; q < Q; q++) {
            float cu = 3.f * (d_cx[q] * ux[idx] + d_cy[q] * uy[idx]);
            float feq = d_w[q] * r * (1.f + cu + 0.5f * cu * cu - 1.5f * (ux[idx] * ux[idx] + uy[idx] * uy[idx]));
            fout[q * WIDTH * HEIGHT + idx] = f[q] + omega * (feq - f[q]);
        }
    }
    
    // --- 可視化(障害物を灰色に設定) ---
    __global__ void visual_kernel(uchar4 *out, const float *ux, const float *uy, const float *rho, const int *bound) {
        int x = blockIdx.x * blockDim.x + threadIdx.x, y = blockIdx.y * blockDim.y + threadIdx.y, idx = y * WIDTH + x;
        if (x >= WIDTH || y >= HEIGHT) return;
        if (bound[idx]) {
            out[idx] = make_uchar4(100, 100, 100, 255); // 灰色に変更
            return;
        }
        float i = tanhf(sqrtf(ux[idx]*ux[idx] + uy[idx]*uy[idx]) * 30.f + fabsf(rho[idx] - 1.f) * 6.f);
        out[idx] = make_uchar4(i * 255, i * 130, 210 * (1 - i) + 40, 255);
    }
    
    void reset() {
        std::vector<float> h_f(Q * WIDTH * HEIGHT);
        for (int i = 0; i < WIDTH * HEIGHT; i++) for (int q = 0; q < Q; q++) h_f[q * WIDTH * HEIGHT + i] = h_w[q];
        cudaMemcpy(d_f1, h_f.data(), Q * WIDTH * HEIGHT * sizeof(float), cudaMemcpyHostToDevice);
    }
    
    void disp() {
        dim3 b(16, 16), g((WIDTH + 15) / 16, (HEIGHT + 15) / 16);
        if (is_dragging && cur_mx != -1) {
            pulse_line_kernel<<<g, b>>>(d_f1, prev_mx, prev_my, cur_mx, cur_my, 0.08f);
            prev_mx = cur_mx; prev_my = cur_my;
        }
        lbm_kernel<<<g, b>>>(d_f1, d_f2, d_rho, d_ux, d_uy, d_boundary);
        std::swap(d_f1, d_f2);
        visual_kernel<<<g, b>>>(d_img, d_ux, d_uy, d_rho, d_boundary);
        cudaMemcpy(h_output_buffer.data(), d_img, WIDTH * HEIGHT * sizeof(uchar4), cudaMemcpyDeviceToHost);
        glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, WIDTH, HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, h_output_buffer.data());
        glClear(GL_COLOR_BUFFER_BIT);
        glBegin(GL_QUADS); glTexCoord2f(0,0); glVertex2f(-1,-1); glTexCoord2f(1,0); glVertex2f(1,-1);
        glTexCoord2f(1,1); glVertex2f(1,1); glTexCoord2f(0,1); glVertex2f(-1,1); glEnd();
        glutSwapBuffers(); glutPostRedisplay();
    }
    
    void mouse(int b, int s, int x, int y) {
        if (b == GLUT_LEFT_BUTTON) {
            if (s == GLUT_DOWN) {
                is_dragging = true;
                cur_mx = prev_mx = x * WIDTH / win_w;
                cur_my = prev_my = (win_h - y) * HEIGHT / win_h;
                pulse_line_kernel<<<dim3((WIDTH+15)/16,(HEIGHT+15)/16), dim3(16,16)>>>(d_f1, cur_mx, cur_my, cur_mx, cur_my, 0.2f);
            } else is_dragging = false;
        }
    }
    
    void motion(int x, int y) {
        if (is_dragging) { cur_mx = x * WIDTH / win_w; cur_my = (win_h - y) * HEIGHT / win_h; }
    }
    
    // 星型判定関数
    bool is_star(float x, float y, float cx, float cy, float r, float inner_r) {
        float dx = x - cx, dy = y - cy;
        float dist = sqrtf(dx*dx + dy*dy);
        float angle = atan2f(dy, dx);
        // 5つの頂点を持つ星の形状パラメータ
        float s = (angle + M_PI/2.0f) * 5.0f / (2.0f * M_PI);
        float f = s - floorf(s);
        float p = (f < 0.5f) ? f : 1.0f - f;
        float star_r = r * (1.0f - p * (r - inner_r) / r * 4.0f);
        return dist < star_r;
    }
    
    int main(int argc, char **argv) {
        glutInit(&argc, argv); glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA);
        glutInitWindowSize(WIDTH, HEIGHT); glutCreateWindow("LBM: Star Obstacle");
        glewInit();
        cudaMalloc(&d_f1, Q*WIDTH*HEIGHT*4); cudaMalloc(&d_f2, Q*WIDTH*HEIGHT*4);
        cudaMalloc(&d_ux, WIDTH*HEIGHT*4); cudaMalloc(&d_uy, WIDTH*HEIGHT*4);
        cudaMalloc(&d_rho, WIDTH*HEIGHT*4); cudaMalloc(&d_boundary, WIDTH*HEIGHT*4);
        cudaMalloc(&d_img, WIDTH*HEIGHT*4);
    
        std::vector<int> h_b(WIDTH * HEIGHT, 0);
        for (int i = 0; i < WIDTH * HEIGHT; i++) {
            int x = i % WIDTH, y = i / WIDTH;
            // 外壁
            if (x == 0 || x == WIDTH - 1 || y == 0 || y == HEIGHT - 1) h_b[i] = 1;
            // 星型障害物 (中心 256, 256 付近、半径 60, 内径 25)
            if (is_star((float)x, (float)y, 256.0f, 256.0f, 65.0f, 25.0f)) h_b[i] = 1;
        }
        cudaMemcpy(d_boundary, h_b.data(), WIDTH * HEIGHT * 4, cudaMemcpyHostToDevice);
        reset();
    
        glGenTextures(1, &tex); glBindTexture(GL_TEXTURE_2D, tex);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, WIDTH, HEIGHT, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
        glEnable(GL_TEXTURE_2D);
    
        glutDisplayFunc(disp); glutReshapeFunc([](int w, int h) { glViewport(0,0,w,h); win_w=w; win_h=h; });
        glutMouseFunc(mouse); glutMotionFunc(motion);
        glutKeyboardFunc([](unsigned char k, int x, int y) { if (k == 'r' || k == ' ' || k == 'R') reset(); });
        glutMainLoop(); return 0;
    }
    

    This project is a real-time fluid dynamics simulator that implements the Lattice Boltzmann Method (LBM) by leveraging the parallel processing power of the GPU. By tightly integrating CUDA for physical computations and OpenGL for rendering, it achieves exceptionally smooth visualization of fluid behavior on a high-resolution grid.

    Technical Highlights

    • Star-Shaped Obstacle: A gray, star-shaped obstacle is positioned at the center of the domain. Utilizing a mathematical Signed Distance Function (SDF) approach based on polar coordinates, the simulator accurately handles wave diffraction and interference against complex geometries.

    • Linearly Interpolated Drag Input: To ensure seamless wave generation regardless of mouse movement speed, a custom kernel interpolates between previous and current mouse coordinates, injecting energy along the continuous path.

    • Zero-Copy Visualization: Physical quantities are converted directly into pixel colors within GPU memory. These are then rendered as OpenGL textures without being transferred back to the CPU, eliminating data bottlenecks and ensuring high frame rates.

    How It Works

    1. Collision & Streaming: The particle distribution functions at each grid point move to neighboring nodes and collide to approach a local equilibrium state.

    2. Boundary Conditions: Particles hitting the star-shaped wall undergo a “bounce-back” process, creating intricate wave patterns around the object.

    3. Visualization: Changes in local velocity and density are mapped to a vibrant color palette using a tanh activation function to prevent color saturation.

乱数を使って円周率もどきを求める

乱数を使った円周率もどきを求めてみる

下記のような正方形と内接する円を考える。正方形の1辺は2としておく。この正方形の中にランダムに点を打った際、円の中にも入る確率は 円の面積 / 正方形の面積。つまり、確率  =  π / 4  となる。

 

 

π  =  正方形内にランダムに点を打った           際に円の中に入る確率は  ×  4  

 

 

 

ランダムに打った点が円の中に入ったかどうか?はx座標とy座標にそれぞれ0~1の乱数を与えてやり、sqrt(x^2  + y^2)  が1以下であれば円の中といえる。

 

 

この処理を実装したものが下記

import random
import math
import time

def calc_rad (x, y):
  return math.sqrt(x*x +  y*y)


def calc_pi ( NN ):
  num  = 0
  in_n = 0
  while num < NN:
    x = random.random()
    y = random.random()
    if calc_rad (x, y) <= 1:
      in_n = in_n  + 1
    num = num + 1

  return ( in_n / num ) * 4


if __name__ == "__main__":
  for j in range (1,9):
    NN = 10 ** j
    start_time = time.perf_counter()
    pi = calc_pi(NN)
    execution_time = time.perf_counter() - start_time
    delt = math.fabs(math.pi - pi)
    print (str(NN).rjust(10),"  ",f'{pi:11.010f}',"  ",f'{delt:7.06f}',"  ",execution_time)

実行結果

   試行回数        結果      誤差     処理時間[sec]
        10    3.6000000000    0.458407    1.1099999937869143e-05
       100    2.9600000000    0.181593    4.040199928567745e-05
      1000    3.0800000000    0.061593    0.0004007130000900361
     10000    3.1616000000    0.020007    0.004138636999414302
    100000    3.1487600000    0.007167    0.042758481000419124
   1000000    3.1437800000    0.002187    0.4172533930004647
  10000000    3.1411992000    0.000393    4.038885087000381
 100000000    3.1415664400    0.000026    41.15365210999971

それなりに近い値かな?

centos7にてpycudaをインストール(pip3)

pycudaを下記コマンドからインストールを試したところ、、、

pip3 install pycuda

エラーでストップ。
pycudaのコンパイル時にcuda.hがないとのメッセージで死んでいる。

このエラーについてググるとコンパイル時のPATH/
CPATH/LIBRARY_PATH等にcudaのpathを追加で
対応可能と出てくるが自分の環境では改善せず。。

自分のところでは、gccのバージョンを上げることでエラーが消えて
インストール成功した。gccの対応バージョンがあるっぽい。

エラー時:gcc version 4.8.5 20150623
成功時 :gcc version 7.3.1 20180303

centos7にcudaをインストール

CUDAはNVIDIAが開発・提供しているGPU向けの汎用並列コンピューティングプラットフォーム。専用のC/C++コンパイラ (nvcc) やライブラリ (API) などが提供されている(wiki)。

NVIDIAのサイトからOS/アーキテクチャにあったToolkitをダウンロード。
https://developer.nvidia.com/cuda-downloads


※wget方式でのコマンドラインが指定される。

wget https://developer.download.nvidia.com/compute/cuda/11.5.1/local_installers/cuda_11.5.1_495.29.05_linux.run
sudo sh cuda_11.5.1_495.29.05_linux.run


acceptと入力してEnter


とりあえず全部にチェック入れてInstall

インストールの際はUIをGUIからCUIに変更しておく(参考)。

 

インストールした結果が下記

UbuntuのPythonでpyQtGraphをインストール

作業メモ
まずはUbuntuにてeasy_installをpipをインストール

easy_installのインストール
$ sudo apt-get install python-setuptools

pipのインストール
$ sudo apt-get install python-pip

pipを使ってpyQtGraphをインストール
$ sudo pip install pyqtgraph

分子動力学による粒子のシミュレーション

粒子の動きを分子動力学で計算しmatplotlibで可視化してみる。
ただし各粒子間には相互作用は働かないシンプルな系として考える。
速度の計算はベルレ法(参考)。

各粒子はお互いが見えていないので、初速のまま真っ直ぐに飛び続ける。境界を越えたら反対側へ。

#-*- coding:utf-8 -*-
import matplotlib.pyplot as plt
import numpy as np
import random

a  = 10
Dt = 0.1

#Define xy index
PX = 0
PY = 1
VX = 2
VY = 3
FX = 4
FY = 5

def integ(xy):
  #verlet integration
  particle_num = len(xy)
  pnum = 0
  while pnum < particle_num:
    #update velocity
    xy[pnum][VX] = xy[pnum][VX] + 0.5*Dt*xy[pnum][FX]
    xy[pnum][VY] = xy[pnum][VY] + 0.5*Dt*xy[pnum][FY]
    #update position
    xy[pnum][PX] = xy[pnum][PX] + Dt*xy[pnum][VX]
    xy[pnum][PY] = xy[pnum][PY] + Dt*xy[pnum][VY]
    #boundary condition
    if xy[pnum][PX] > a:
       xy[pnum][PX] -= a
    if xy[pnum][PY] > a:
       xy[pnum][PY] -= a
    if xy[pnum][PX] < 0:
       xy[pnum][PX] += a
    if xy[pnum][PY] < 0:
       xy[pnum][PY] += a
    pnum += 1


def update(i, fig_title, xy, total_step):
    if i != 0:
        plt.cla()
    ax.set_xlim(0,10)
    ax.set_ylim(0,10)
    integ(xy)
    nnum = 0
    while nnum < len(xy):
       plt.plot(xy[nnum][PX], xy[nnum][PY], "o")
       nnum = nnum + 1
    plt.title(fig_title + ' step = ' + str(i) + '/' + str(total_step))


def init_set(xy):
  pnum = 0
  xv_sum = 0
  yv_sum = 0
  particle_num = len(xy)
  while pnum < particle_num:
    xy[pnum][PX] = 0  # x-position
    xy[pnum][PY] = 0  # y-position
    xy[pnum][VX] = random.uniform(-1,1) # x-velocity
    xy[pnum][VY] = random.uniform(-1,1) # y-velocity
    xv_sum += xy[pnum][VX]
    yv_sum += xy[pnum][VY]
    xy[pnum][FX] = 0   # x-force
    xy[pnum][FY] = 0   # y-force
    pnum += 1
  #Total momentum to zero
  xv_sum = xv_sum / particle_num
  yv_sum = yv_sum / particle_num
  pnum = 0
  while pnum < particle_num:
    xy[pnum][VX] = xy[pnum][VX] - xv_sum
    xy[pnum][VY] = xy[pnum][VY] - yv_sum
    pnum += 1


if __name__ == "__main__":
    fig = plt.figure(figsize = (10, 6))
    fig.set_size_inches(10,10)
    ax = fig.add_subplot(111)
    perticle_num = 100
    xy = [[0 for i in range(6)] for j in range(perticle_num)]
    init_set(xy)
    total_step =400
    ani = anm.FuncAnimation(fig, \
                        update, fargs = ('Molecular Dynamics ', xy, total_step), \
                        interval = 10, frames =  total_step)
    plt.show()
    #ani.save("Sample_MD.gif", writer = 'imagemagick', dpi=50)


  

実行の結果は下記。
Molecular dynamics simulation sample

オンラインでのgif圧縮

matplotlibで生成したgifをwebへアップする際に、
少しでもgifファイルサイズを小さくするため下記サイトを使ってみた。

https://compressor.io/

結果は下記。余白が多いようなgifアニメは結構圧縮が効く様子。
930KB -> 530KB

cygwinでmatplotlibとscipyを使えるようにする

↓こちらのサイトを参考に、そのままの手順でできました。
http://ssmn0621.blogspot.jp/2016/03/cygwinnumpyscipymatplotlib.html

■pipのインストール
%easy_install-2.7 pip

■scipyのインストール
%pip install scipy

■matplotlibのインストール
%easy_install-2.7 matplotlib==2.0.0

■実行テスト
setenv DISPLAY :0.0 #DISPLAY設定
run xwin -multiwindow -noclipboard #xを起動
python rect.py

■実行結果
matplotlibで描画した長方形図形

■rect.pyの中身は↓

# -*- coding: utf-8 -*-
import matplotlib
import matplotlib.pyplot as plt

def main():
    fig = plt.figure()
    ax = fig.add_subplot(111)
    # 始点(0.2,0.2)で幅が0.2, 高さが0.4の長方形を描画
    rect = plt.Rectangle((0.2,0.2),0.2,0.4,fc="#770000")
    ax.add_patch(rect)
    plt.show()

if __name__ == '__main__':
    main()

※easy_install , pip
共にpython用外部ライブラリのパッケージ管理ツール
easy_installの方が古いらしく、pipはこれらを置き換える
 目的で開発されている。

任意の数のランダムなxy座標を生成する

リスト形式でappendしてやることで、指定数を追加できる。
xyzならappendで追加する要素を3つにすればOK

#-*- coding:utf-8 -*-

import random

def init_xy(list,n):
    i = 0
    while i < n:
       list.append([random.random(),random.random()])
       i += 1


if __name__ == "__main__":
    n = 10
    xylist = []
    init_xy(xylist,n)
    print xylist

関数に指定した引数は参照渡し

Pythonは関数に指定した引数がすべて参照渡しとなるらしい。
ただし、指定した引数の型によっては変更不可となる。

変更不可(イミュータブル)
int, float, str, tuple

変更可(ミュータブル)
list, set, dict

↓参考
http://docs.python.jp/2/reference/datamodel.html

■変更不可例

#-*- coding:utf-8 -*-

def test(gg):
    gg = gg * 2

if __name__ == "__main__":
    x = 10
    print(x)  ## 結果 10
    test(x)
    print(x)  ## 結果 10

■変更可例

#-*- coding:utf-8 -*-

def test(list):
    list[0] = list[0] * 2

if __name__ == "__main__":
    list = []
    list.append(10)
    print list[0]   ## 結果 10 
    test(list)
    print list[0]   ## 結果 20

id関数を使ってみると、関数内でイミュータブル変数を変更すると識別子が変わった。
(ミュータブルは識別子変わらず)

#-*- coding:utf-8 -*-

def test(list,x):
    print id(list),id(x)   # 7696579635896 25769969816
    list[0] = list[0] * 2
    x += 1
    print id(list),id(x)   # 7696579635896 25769969792
if __name__ == "__main__":
    x = 1
    list = []
    list.append(10)
    print list[0],x       # 10 1
    print id(list),id(x)  # 7696579635896 25769969816
    test(list,x)
    print list[0],x       # 20 1
    print id(list),id(x)  # 7696579635896 25769969816