テクスチャスクロールで画面をリッチに!(シェーダー入門)

はじめに

こんにちは!新人エンジニアの王です。
シェーダーに対して「数学や物理が必須で難しい」というイメージをお持ちではありませんか?実は、ノイズ画像とテクスチャスクロールを組み合わせるだけで、リッチな表現は十分に可能です!本記事では、これらを活用して炎を4ステップで実装する方法を解説します。難しそうというイメージから自分にもできそうと感じていただけると幸いです。

※ C++側のコードについて
本記事は、ピクセルを直接操作するシェーダプログラム(GLSL)を扱います。C++側の描画設定や初期化処理は、使用するライブラリやフレームワークによって大きく異なります。そのため、本記事ではC++による実装コードは割愛し、シェーダープログラムのみをご紹介いたします。

まずは完成形


今回の実装ではこちらの炎を実装します。
色と形を計算して変化させるだけでこのような炎を実装できます。

使用環境紹介

C++開発環境 : 本記事ではOpenGLを使用し、以下のライブラリを使用します。
GLFW : ウィンドウの作成や入力処理に必要
GLEW : OpenGLの拡張機能を使用するために必要
GLM : ベクトルや行列(glm::vec2 や translate)の計算に必要
IDE : Visual Studio 2022 など
使用する画像

今回は「パーリンノイズ」を使用します。これが炎の「ゆらぎ」の素になります。

ステップ1 : 画像を表示する

ゼロからなのでまずは画面に画像を表示するところから始めましょう!シェーダープログラムの基本であるVertexShaderFragmentShaderを用意します。

VertexShader
#version 330 core

// C++から送信される頂点データ
layout (location = 0) in vec3 aPos;      // 頂点位置
layout (location = 1) in vec2 aTexCoord; // UV座標

// ピクセルシェーダに渡すデータ
out vec2 vTexCoord;

// C++から受け取る変換行列
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 行列を使って頂点位置を計算 (3D空間→2D画面への変換)
    gl_Position = projection * view * model * vec4(aPos, 1.0);

    // UV座標をそのままピクセルシェーダへ渡す
    vTexCoord = aTexCoord;
}
FragmentShader
#version 330 core

// 画面に出力する最終的な色
out vec4 FragColor;

// 頂点シェーダから受け取ったUV座標
in vec2 vTexCoord;

// C++から受け取るテクスチャ画像
uniform sampler2D texture1;

void main()
{
    // texture()関数で、指定された座標の色をそのまま出力
    FragColor = texture(texture1, vTexCoord);
}

シェーダ―側は上記2種のコードだけで、Textureの表示が行えます。C++側での描画処理が大変ですが、シェーダで行っていることはこれだけです。VertexShaderはこれ以降の変更はありません!

※VertexShaderとFragmentShaderの簡単な説明
VertexShaderは頂点ごとに実行される処理で、「画面上のどこに頂点を配置するか」を決めて形や位置を決めています。FragmentShaderはピクセルごとに実行される処理です。1つ1つのピクセルの色を決めています。

ステップ2 : 画像をスクロールする

次に表示した画像を動かしてみます!画像を動かすためには「時間」の情報が必要です。現状のシェーダ―では「今が何秒か」を知る方法を持っていません。そこで、以下の2つをシェーダに記述して時間による動きの処理をします。
時間を受け取る:C++側から送られてくる時間データを受け取る変数を用意します
テクスチャスクロール:受け取った時間を使用して、テクスチャの読み取り位置(UV座標)をずらす。

座標に時間を足し込むというテクニックが今回の目玉であるテクスチャスクロールです。

FragmentShader
#version 330 core

// 画面に出力する最終的な色
out vec4 FragColor;

// 頂点シェーダから受け取ったUV座標
in vec2 vTexCoord;

// C++から受け取るテクスチャ画像
uniform sampler2D texture1;

// C++から時間を受け取るための変数
uniform float uTime;

void main()
{
    //スクロール速度
    vec2 scrollSpeed = vec2(-0.5, -0.5);

    // 時間経過に合わせて「ずれ量(オフセット)」を計算
    vec2 uvOffset = uTime * scrollSpeed;

    // 元のUV座標にオフセットを足す (これがテクスチャスクロール!)
    vec2 scrolledUV = vTexCoord + uvOffset;

    // ズレた座標の色を取得して出力
    fragColor = texture(texture1, scrolledUV);
}

上記のFragmentShaderを適用したものがこちらです↓


パーリンノイズではわかりずらかったので別の画像で実行しています!

ステップ3 : ノイズ強度に応じた色付けと形の調整

前ステップで動かしたノイズ画像の、ノイズ強度(テクスチャの明るさ)に応じて色を塗り分け、炎らしいグラデーションと形を作ります!このステップでも2点実行します。
pow関数による形状の調節 : 炎の上部を細くして、自然に消えるように調整します
if文によるグラデーション : ノイズの強度に応じて、 赤 → 黄 → 白と色を変化させます。

FragmentShader
// main()より上部は変更なしのため省略

void main()
{
    // スクロール速度 (真上方向に変更)
    vec2 scrollSpeed = vec2(0.0, 0.8);

   // 時間経過に合わせて「ずれ量(オフセット)」を計算
    vec2 uvOffset = uTime * scrollSpeed;

    // 元のUV座標にオフセットを足す
    vec2 scrolledUV = vTexCoord + uvOffset;

    // ノイズテクスチャをサンプリング
    float noiseIntensity = texture(texture1, scrolledUV).r;

    // 炎の形状を作るために、画像上部になるほど炎が弱くなるという型枠を作ります
    float gradient = 1.0 - vTexCoord.y;

    // 累乗(pow関数)を使用して、上部を細くする ← この処理をなくすと直線的な炎になります
    gradient = pow(gradient, 1.5);

    // 最終的な炎の強度を決定 (炎の型枠外を透明にするマスキングを行う)
    float intensity = noiseIntensity * gradient * 4.0;

    // 強度に応じて色を変えます
    vec3 finalColor = vec3(0.0);
    if(intensity > 1.2)
    {
        // とても強い -> 白 (芯の部分)
        finalColor = vec3(1.0, 1.0, 1.0);
    }
    else if(intensity > 0.6)
    {
        // 強い -> 黄色
        float t = (intensity - 0.6) / (1.2 - 0.6);
        finalColor = mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 1.0, 0.0), t);
    }
    else if(intensity > 0.2)
    {
        // 普通 -> 赤	
        float t = (intensity - 0.2) / (0.6 - 0.2);
        finalColor = mix(vec3(0.2, 0.0, 0.0), vec3(1.0, 0.0, 0.0), t);
    }
    else
    {
        // 弱い -> 黒 (透明へ)
        finalColor = vec3(0.0, 0.0, 0.0);
    }

    // アルファ値の決定
    float alpha = clamp(intensity, 0.0, 1.0);

    // 最終的な出力
    fragColor = vec4(finalColor, alpha);
}

実行結果がこちら↓

ステップ4 : 二重スクロールによるリアルな炎の実現

最後に、ノイズテクスチャを1枚使用して炎の表現をよりリアルにする二重スクロールを行います。このテクニックは、速度と方向が異なる2つのノイズパターンを掛け合わせることで複雑なゆらぎを表現します。

FragmentShader
// main()より上部は変更なしのため省略...

void main()
{
    // 2つのスクロール方法と速度を定義
    vec2 scroll1 = vec2(0.0, uTime * 0.8);	        // 真上に高速
    vec2 scroll2 = vec2(uTime * 0.4, uTime * 0.3);	// ゆっくり斜め

    // ノイズテクスチャを2回サンプリングする
    float noise1 = texture(texture1,vTexCoord + scroll1).r;
    float noise2 = texture(texture1,vTexCoord + scroll2).r;

    // 2つのノイズを合成する
    float combinedNoise = noise1 * noise2;

    // 炎の形状を作るために、画像上部になるほど炎が弱くなるという型枠を作ります
    float gradient = 1.0 - vTexCoord.y;

    // 累乗(pow関数)を使用して、上部を細くする ← この処理をなくすと直線的な炎になります
    gradient = pow(gradient, 1.5);

    // 最終的な炎の強度を決定 (炎の型枠外を透明にするマスキングを行う)
    float intensity = combinedNoise * gradient * 4.0;

    // 強度に応じて色を変えます
    vec3 finalColor = vec3(0.0);

    // 以降の処理も変更なしなので省略...
}

上記コードで処理を実行すると、先にお見せした完成動画の挙動が確認できます。

まとめ

今回の記事では、テクスチャ座標(UV)を時間でずらすというテクスチャスクロールを応用して、リアルな炎を表現できました。シェーダープログラミングは行列計算やベクトルの扱いなど、最初は難しく感じるかもしれません。この記事を読んで少しでも興味を持っていただけると幸いです!
では!