e.blog

主にUnity/UE周りのことについてまとめていきます

ドメインワープをテクスチャフェッチで実装する

概要

以前、フラクタルブラウン運動とドメインワープという記事を書きました。
ここで紹介した方法は、ドメインワープの計算のためにランタイムでノイズを計算する方法でした。

ちなみにドメインワープによるアニメーションはこんな感じのものになります↓

f:id:edo_m18:20200530154711g:plain

が、これはそこそこ処理負荷が高く、これをOculus Questなどの非力なマシン上で実行すると目に見えてFPSが落ちてしまいます。
そこで今回は、このノイズ計算をテクスチャフェッチに置き換えて軽量化した話を書きます。

ちなみになんとなーくイメージが湧いているだけで、実際にそれぞれの処理が具体的に(数学的に)どういう意味があるか、という点については理解しきれていません。
あくまでOculus Questなどの非力なマシンでもそれっぽく動いた、というのをまとめたものになります。

上の動画の動作版はShadertoyにアップしてあるので実際に動くものを見ることができます。

今回実装したものをUnity上で再生したものは以下のような感じになります。
ドメインワープをテクスチャフェッチ版に置き換え、さらにそれが上に流れていくようにして炎っぽいエフェクトにしてみました。

必要なテクスチャは下で解説する「パーリンノイズで作ったテクスチャ1枚だけ」なので、結構汎用的に使えるのではないかなと思います。

解説

ドメインワープ自体の仕組みについては以前書いたこちらの記事(フラクタルブラウン運動とドメインワープ)を参照ください。

ドメインワープの主役は、ノイズ関数を、異なるスケールで足し合わせるfBM(フラクタルブラウン運動)です。
これを実行して1枚の画像を作ると以下のような画像を作る(計算する)ことができます。

f:id:edo_m18:20200530155439j:plain

冒頭のShadertoyの例では、これをランタイムで計算することにより実現していました。
ただ、ここの処理を、各フラグメントごとに実行するのは相当負荷が高いので、ここを省略したい、というのが今回の記事を書くに至った理由です。

大まかな動作原理

今回達成したいことは処理負荷の軽減です。
そのほとんどがノイズ関数の重ね合わせの計算です。

そして上の図を見てもらうと分かりますが、これをわざわざランタイムで計算しなくても良いわけですね。

ランタイムで計算するメリットは、無限に広がるノイズ空間を使える点でしょうか。
一方、テクスチャを利用する場合はある程度の精度の問題が出てくることがあるかもしれません。

が、そこらへんはスケール調整などでごまかせる範囲かと思います。

今回はこの「テクスチャフェッチ」を利用して計算負荷を下げるアプローチを取ります。

ドメインワープをもう一度

さて、ではどうテクスチャフェッチしたらいいでしょうか。
ということで、ドメインワープのやっていることを少し深堀りしてみましょう。

まずはシェーダのコードを見てみましょう。

vec2 q = vec2(0.0);
q.x = fbm(st + vec2(0.0));
q.y = fbm(st + vec2(1.0));

// These numbers(such as 1.7, 9.2, etc.) are not special meaning.
vec2 r = vec2(0.0);
r.x = fbm(st + (4.0 * q) + vec2(1.7, 9.2) + (0.15 * iTime));
r.y = fbm(st + (4.0 * q) + vec2(8.3, 2.8) + (0.12 * iTime));

float f = fbm(st + 4.0 * r);

fbm関数の呼び出しが計5回行われているのが分かります。
そしてその引数に注目すると、そのどれもが『ひとつ前のfbmの計算結果を利用して』います。

イメージ的には積分を繰り返していくような感じでしょうか。
最初が加速度で、その加速度から速度を算出し、そして最終的な位置を計算しているような。

要は、『ノイズ関数によって得られた位置を使って次の位置を決定することを繰り返す』というのが本質です。

なので、この考え方をテクスチャフェッチの位置にも適用してやることを考えてみます。

ということでまず、今回実装したUnityのシェーダの全文を載せます。(そんなに長くないので)

Shader "Unlit/FireEffect"
{
    Properties
    {
        _Color1("Color1", Color) = (1, 1, 1, 1)
        _Color2("Color2", Color) = (1, 1, 1, 1)
        _Color3("Color3", Color) = (1, 1, 1, 1)
        _Scale ("Scale", Float) = 0.01
        _NoiseScale("Noise Scale", Float) = 10.0
        _Speed("Speed", Float) = 1.0
        _PerlinNoise("Perlin Noise", 2D) = "white" {}
    }
        SubShader
    {
        Tags { "RenderType" = "Transparent" "Queue" = "Transparent" }
        LOD 100

        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha
            ZWrite Off

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            fixed _Scale;
            fixed _NoiseScale;
            fixed _Speed;
            fixed4 _Color1;
            fixed4 _Color2;
            fixed4 _Color3;
            sampler2D _PerlinNoise;

            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float k = _Scale;

                float2 st = i.uv * _NoiseScale;

                float3 q = tex2D(_PerlinNoise, st * k).rgr;

                float2 r;
                r.xy = tex2D(_PerlinNoise, st * k + q + (0.15 * _Time.x)).rg;

                float2 puv = (st + r) * 0.05;
                puv.y -= _Time.x * _Speed;

                float f = tex2D(_PerlinNoise, puv);

                float coef = (f * f * f + (0.6 * f * f) + (0.5 * f));

                fixed4 color = fixed4(0, 0, 0, 1);
                color = lerp(_Color1, _Color2, length(q.xy));
                color = lerp(color, _Color2, length(r.xy));
                color *= coef;

                // for alpha
                float2 uv = abs(i.uv * 2.0 - 1.0);
                float2 auv = smoothstep(0, 1, 1.0 - uv);

                float a = auv.x * auv.y;

                float3 luminance = float3(0.3, 0.59, 0.11);
                float l = dot(luminance, coef.xxx);

                l = saturate(pow(1.0 - l, 5.0));

                color.rgb = lerp(color.rgb, _Color3, l);

                color.a = a;

                return color;
            }
            ENDCG
        }
    }
}

大事な点を抜粋すると以下です。

float k = _Scale;

float2 st = i.uv * _NoiseScale;

float3 q = tex2D(_PerlinNoise, st * k).rgr;

float2 r;
r.xy = tex2D(_PerlinNoise, st * k + q + (0.15 * _Time.x)).rg;

float2 puv = (st + r) * 0.05;
puv.y -= _Time.x * _Speed;

float f = tex2D(_PerlinNoise, puv);

float coef = (f * f * f + (0.6 * f * f) + (0.5 * f));

まず最初でUVにスケールを掛けています。これによって最初にフェッチするテクスチャの位置を調整しているわけですね。
そしてその引数をさらに0.01倍しています(_Scaleのデフォルト値は0.01)。このあたりはわりと適当な数値で、自分が望んだ形になるように調整するためのパラメータです。
他でも同様の値を使うために変数にしているに過ぎません。(もちろん、他の部分で使用しているkを別の数値にしても大丈夫です)

次の部分では、フェッチした値(q)をstに加算しています。
若干値を加工していますが、これは、時間経過を利用することで徐々に変化を生むためのものです。

最終的にrが最後のフェッチする値となり、得られた値をf3+0.6f2+0.5fの式でなめらかにしたものを係数として利用しています。
ちなみにこの式をグラフ化すると以下のようななめらかに変化するグラフになります。

f:id:edo_m18:20200530172049j:plain

最後に

水のような、ちょっと神秘的な流体表現を手軽に行えるので、ちょっとしたアクセントに使うといいかもしれません。
今回の調整によってOculus Questでも、複数配置しても問題ないレベルで動作しているので利用範囲は多いかなと思っています。