すべてを「Normalize」せよ

はじめに

今回は、エフェクト制作で少しテクニカルなことをする時によく使う、「Normalize(標準化/正規化)」について、解説したいと思います。

※誤解のないように補足ですが、すべてをNormalizeする必要はありません。ただかっこいいタイトルを付けたかっただけです…

Normarize自体は数学用語で、エフェクト制作はおろか、CG制作以外にも統計学などあらゆる所で出てくる概念です。また、使用される領域で、言葉の意味や日本語訳や計算内容が異なったりしますので、厳密な話をここでするつもりはありません。

また、Normarizeをすることは、知識というよりも、効率化や汎用化などのための考え方や習慣に近いものがあるので、ShaderやNiagara、Houdiniなどで、少し数学的なことをするアーティストもぜひ身に付けておいた方が良いものかと思います。

Unreal Engineでの例や活用事例を用いて説明していきますが、考え方自体はツールに関係ない話なので、UEを使わない人もぜひ目を通してみてください!

Normalizeってなに

CG制作で、Normalizeをする場合、基本、一つの数値(float値、Scalar値)をNormalizeすることと、ベクトル(Vector2,3,4など)をNormalizeすることと2つあります。

大元の概念はどちらも変わらないのですが、説明や活用事例としては分けた方が分かりやすいので分けて説明しつつ、今回は前者の一つの数値についてのNormalizeにフォーカスして解説します。

で、Normalizeをざっくり一言でいうと、「値の範囲を0~1にする」です。単純ですね。
ちなみに、ベクトルのNormalizeは、「ベクトルの長さを1にする」です。似てる感じがしますよね。

値の範囲を0~1にするというのがどういうことか、具体的にNiagaraの「Normalized Age」を例に説明します。

Normalized Age とは

名前の通り、NormalizeされたAgeという意味になります。Ageは「歳」という意味なので、各Particleが発生してから経過した秒数がAgeです。

例えば、Lifetime(Particleの寿命)を2秒に設定している時は、各ParticleのAgeは発生してからの経過秒数の値が入り、それは2秒になったタイミングで消滅します。つまり、Ageは0~2の値になるわけです。

そして、このAgeという値をNormalizeするとどうなるかというと、元々0~2の値であったものを、0~1の値の範囲に収めるので、図のように、Ageが1の時は、0.5、2の時は1になるわけです。

どんな利点がある?

では、Ageを0~1の範囲のNormalized Ageにすることでどんな利点があるでしょうか。
Normalized Ageが実際どう使われているか見てみましょう。

Particleの寿命に合わせて、色や大きさ、透明度などを変化させたいことは、エフェクトを作る上でほとんど全ての場合でできてきますよね。
実際に、Particleをフェードアウトさせる場合、Scale Colorを入れて、AlphaのところにCurveを設定することが多いと思います。

その際、デフォルトでNormalized AgeがCurveIndexに設定されています。

Curveの読み方は、縦軸が結果として返される値(=Scale Alphaに設定される値)で、横軸がCurveIndex(=NormalizedAge)の値になります。つまり、NormalizedAgeが0の時、Scale Alphaは1になり、NormalizedAgeが0.5の時、Scale Alphaは0.5になり、NormalizedAgeが1の時、Scale Alphaは0になります。

なので、上記画像のように設定されている場合は、Paritcleが発生から時間が経つと共に徐々にAlphaが小さくなっていって、Particleが死ぬ時に完全に0になる処理がされています。

さて、仮にParticleのLifetimeを2秒に設定した場合は、上とまったく同じ処理が以下の画像のようにすることでも再現できます。

CurveIndexに、NormalizedAgeの代わりにAgeを入れ、カーブの直線の終点を、横軸が2の時に結果が0になるように設定しています。

これでも、Particleが発生してから徐々にAlphaが小さくなり、Particleが死ぬ2秒後に完全に0になり、NoramalizedAgeを使った時と全く同じ挙動になります。

ですが、これは「ParticleのLifetimeを2秒にした」時だけに限定されます。例えば、Lifetimeを1秒に設定していれば、Alphaが0.5の時にParticleが死んでパッと消えてしまい、Lifetimeが3秒ならParticleが死ぬ前にAlphaが0になってしまいます。

実際にParticleを複数発生させる時は、Random Rangeなどを使いランダムな値を取らせることが多いです。なのでParticle毎にLifetimeの値が異なるので、仮に上記のようにAgeが2(=発生から2秒後)の時にAlphaを0にするというカーブを作ってしまうと、Lifetimeが異なるParticleでフェードアウトの仕方が変わってしまいます。

そこで、Normalized Ageの出番になります。

各ParticleのLifetimeが何であれ、NormalizedAgeは、Particleが発生してから死ぬまでの長さを0~1で表してくれます。Lifetimeが2秒だろうと、1秒だろうと、3.45秒だろうと、Particleが発生した時が0で、Lifetimeの半分まで経過したら0.5、死ぬ時に1の値を取ります。

そして、そのNormalizedAgeを使ってカーブを設定することで、Lifetimeの長さに関わらず、各Particleで発生してから死ぬまでのフェードアウトを等しく処理することができるのです。

ここでチャレンジ問題です!(答えは一番下のまとめに記載します)

Normalized Ageはどのようにしたら計算できるでしょうか?考えてみましょう!

Lerpと組み合わせて活用してみる

NormalizedAgeの例だけでは、まだまだNormalizeの良さがピンとこないと思うので、他の簡単な活用事例も見てみましょう。

自分は、開始と終了の値を細かく調整したい、もしくは開始と終了の値とその遷移のカーブを切り離して調整したい時に、良くLerpとNormalizeを組み合わせて使います。

ちなみに、Lerpは、AとBとAlphaという3つの入力値があり、Alphaの値をAとBの比率として値を返します。

Alphaが0であれば、Aの比率が100%でAの値を返し、Alphaが0.5であれば、Aが50%・Bが50%でAとBの中間の値を返し、Alphaが1であれば、Bが100%でBの値を返します。これを線形補間と言います。

さて、具体的にどんな時にLerpとNormalizeを組み合わせて使うかというと、例えば、Textureの値にPowerをかけてExpの値をDynamicParamterで動的に変えたい時などに、自分はよく使用します。(画像のMaterialはシンプルな例です)

グラデーションのあるTextureにPowerをかけてフェードアウトさせることで、Textureの質感を上手く反映させたフェードをさせることができますが、その際に動的に制御するExpの値は、ルックの結果を見ながら細かく調整することが多いです。

その際、普通のやり方は以下のように、カーブだけ使って値と遷移の緩急のアニメーションを設定することだと思います。

ただ、これの問題点は、値を変えたときに緩急のアニメーションも変わってしまい、値を変えるたび、カーブを調整しなおさなければならないことです。

これだと、値を細かく調整したいのに、調整する度に緩急の具合も変わってしまい、カーブの調整もしなければならなくなって、効率が悪いです。

こういう時、自分の場合はLerpを使い、Alphaの値をカーブにするとともに、縦軸の値を0~1にしたカーブを作ります。

こうすることで、Alphaの値が0の時は、LerpのAの値になり、1の時にBの値になるので、開始と終了の値はLerpのAとBで設定し、遷移のアニメーションの緩急自体はカーブで設定することができるので、開始や終了の値を変えてもカーブが変化せず、緩急の具合を維持したまま値の調整を細かく行うことができるようになります。

つまり、値の設定と緩急のカーブの設定を別々で制御できるようにすることで、片方を変えたら片方も変えなければならないということがなくなり、効率的に値の調整ができます。

Normalizeされたアトリビュート自体は使っていませんが、Normalizeという考え方をここでは活かしています。すなわち、開始と終了の値をカーブの設定の時にいちいち弄るのではなく、カーブのアウトプットは0~1の範囲にしてしまい、その0~1を使ってLerpで開始と終了の値を調整するという考え方をするということです。

これが冒頭で述べた、Noramlizeは知識というよりも、考え方や習慣に近いという意味です。

もう少しだけNormalizeの良さがわかってきたのではないでしょうか?

※ここで挙げたLerpを使った例は、普通にカーブを設定するよりもLerpを使う分少し余計な処理負荷がかかります。負荷にシビアな場合は少しでも処理を最適化した方が良いと思いますが、Lerpを挟むことで劇的に処理負荷があがるわけでもないので、調整の効率化のメリットと天秤にかけて対応するのが良いと思います。

活用事例その1~高く立ち昇る煙~

さて、細かいNormalizeの活用事例を見てもあまり楽しくないかもしれないので、Normalizeを活用することで作れるエフェクトを紹介します。

画像に alt 属性が指定されていません。ファイル名: TestBed-Unreal-Editor-2024-08-21-01-41-43_1.gif

背景エフェクトなどで、高く立ち昇る煙のエフェクトを作成する際、みなさんはどのように作りますか。

VelocityやForceなどを駆使して作ろうとするかもしれませんが、それはあまり良くないやり方というか、理想的な見た目を作るのにパラメータを調整するのがとても難しいと思います。また、高く立ち昇る所まで確認するのにParticleが移動するまで待たなければならずイテレーション効率も悪いです。

今回の作り方は、発想を変えて、この位置にあるものは、こういう見た目になるはず、と物理的な挙動を逆算してエフェクトを制御していきます。

具体的な作り方に入る前に、高く立ち昇る煙はどのような見た目になるのが自然でしょうか?

  • 炎の発生源から出る煙は、熱せられて勢いよく上昇する。煙の色も煤が濃く黒めの色になる。
  • 上昇して発生源から離れるにつれて、煙は拡散し広がっていくと共に、上空の風の影響を受けてその方向にたなびく。また煙が拡散することで灰色寄りに薄くなっていく。

このように煙の挙動を書き出してみると、「煙の見た目は、炎からの距離=高さに依存して変わる」ということがわかってきます。そして、高くなればなるほど、勢いが弱まり、風の影響を受け、拡散していき、色が薄くなります。

なので、Particleの位置(高さ)を元に、VelocityやSize、Colorなどを変えてあげることで、そのような見た目が作れるのではないでしょうか。実際にやってみましょう。

作成手順

上記で考察したことをもとに、以下のような手順で作成していくます。

  1. 煙を出したい高さを決め、その高さの範囲にParticleを出す。
  2. 各Particleのポジションのz(=高さ)をNormalizeしたアトリビュート(NormalizedHeight)を作る。
  3. NormalizedHeightをもとにカーブ(HeightBias)を作る。
  4. HeightBiasとLerpを使って、SizeやColorなどのアトリビュートを制御する。

高さをNormalizeする

まずは、作成手順の1と2の部分をやってみましょう。

HeightというアトリビュートをEmitterにつくり、10000に設定しています。

それをShape Location(Cylinder)のCylinder Heightに設定することで、zが0~10000の間にランダムにParticleを発生させます。

次に、NoramlizedHeightというアトリビュートをParticleに作り、各ParticleのポジションのzをHeightで割ることで、高さをNormalizedした値が設定できます。簡単ですね!

Normalizeした高さをもとにカーブを作る

HeightBiasというアトリビュートを作り、その値をカーブで設定します。CurveIndexにNormalizedHeightを入れることで、高さが高くなるにつれて、どう値を変化させるかをカーブで調整できるようになります。

HeightBiasがどう使われるか見ないと、いまいちこれの意味の想像がつきにくいと思うので、上空にいくほど風の影響を受けてたなびくという部分を見ていきたいと思います。

風の影響を受けてたなびくという挙動を、単純にParticleの発生する位置を変えることで表現します。つまり、元々一直線上に出ていたParticleを、上の方にあるParticleほど、指定した風の方向に移動させます。

まず、WindDirectionとWindIntensityというパラメータをEmitterに作り、風の向きと強さを設定します。

そしてあとは単純に、元のPositionに、WindDirection×WindIntensity×HeightBiasの値を足しているだけです。

HeightBiasは、NormalizedHeightが0の時は、0になるので、結局WindDirection×WindIntensity×HeightBiasの値は0になり、元のPositionから移動しません。

逆に、NormalizedHeightが1の時、HeightBiasは1になるので、WindDirection×WindIntensity×HeightBiasの値は、WindDirection×WindIntensityになり、WindIntensityに設定した値分だけ、WindDirectionの方向に移動することになります。

このように、NormalizedHeight(Partilceの高さ)に応じて移動量が制御できます。そしてその制御具合を、HeightBiasによるカーブで調整できるのです。

例えば、カーブを以下のように直線にすれば、Particleの移動量も直線的に変化します。

カーブの調整で、Particleの高さに応じた移動具合を自由に調整できます。これがHeightBiasを設定している意味になります。

HeightBiasとLerpで各種アトリビュートを高さに応じて変化させる

まずは、上空にいくほど拡散する煙の感じを出すために、Particleの発生位置を上にあるものほど球上に広げて、Particleの大きさも大きくしましょう。

上述の風の影響によるParticleの移動具合もそうですが、高さが0の時に、単純に値も0になってよいときは、普通にHeightBiasを乗算すれば問題ないです。発生位置を広げるのも、高さが低いものはほとんど影響しなくてよいので、単純にShapeLocation(Sphere)のSphere Radiusを2000×HeightBiasに設定しています。

SpriteSizeの方は、大きさが0になっては困るので、Lerpを使い、AlphaにHeightBiasを入れることで、HeightBiasが0の時と、1の時の値を指定して、制御するようにします。

これと同じ要領で、各種パラメータを調整します。

LifetimeとSubUVAnimationのスピードを上にあるParticleほど、長生きでゆっくり動くように調整
Initial VelosityやAccelarationは上空ほど弱く、Curl Noise Forceは上空ほど強くなるように調整
低いほど黒く、高いほど薄くなるようにColorを調整

これでNormalizeを活用した高く立ち昇る煙の基礎的な仕組みの作成は終わりです!

Normalizeの強み

ここまでで、高さをNormalizeした値を使うことで、色々な値の制御が楽になるのを見てきましたが、真骨頂はここからです。

背景エフェクトを作る時、同じエフェクトを複数使いまわして配置していくと思いますが、同じエフェクトだからといって、全く同じ見た目になっては困ります。その際には、色々なパラメータをランダムにしてバラつきを出すと思います。

立ち上る煙のエフェクトも同様に、当然高さをある程度の幅でランダムにしたいと思います。そしてそれができます。できるだけでなく、高さをランダムにしても、他の処理の調整をする必要がありません!

なぜなら、Normalizeした高さを使って各パラメータの制御をしているので、高さが10000でも800でも15000でも、その後の制御に使っているNoramlizedHeightの値は0~1になっているからです。

なので、一つのエフェクトをつかって、以下のようなバラつきを、ただ配置するだけで実現できます。

このように、入力される値を気にせず、その後の処理を作れるところが、Normalizeの強みになります!

活用事例その2~プロシージャル雷~

Normalizeを活用した制作事例の二つ目になりますが、こちらは基礎の仕組み部分だけをサクッと説明するのに留めようと思います。

この雷エフェクトは、任意のStaticMeshの表面からランダムに2点を取ってきて、そこを繋いだ雷をつくります。一つのエフェクトで、どんなMeshでも自動でそれっぽい雷表現を作れるところが良いところです。

基礎の仕組みは、以下の画像のようにMesh上の2点をRibbon Rendererで繋ぐ所なので、そこの解説をしたいと思います。

全体の流れ

上記のシンプルに2点間を繋いだだけのものは、2つのEmitterで構成されています。

Anchorで、指定したStaticMeshの2点のPositionとNormalを取得し、ThunderはそのアトリビュートをParticle Attribute Readerで読み込み、その情報を活用して処理しています。

2点間をRibbon Rendererで結んでいるThunderの所で、Normalizeを活用しているので、そこの解説をしていきます。

Ribbon(=Particle)の配置

Ribbon Rendererは、結局の所、Particleを、各Particleが持つRibbonLinkOrderで結んだMeshを表示するという機能なので、Particleをどう配置するかを考えればOKです。

まずは、AnchorのEmitterで、StaticMeshLocationを使って、StaticMesh上の2点のPositionとNormalを取得し、Start/EndPosition、Start/EndNormalというアトリビュートを作ります。

それを、ThunderのEmitterでParticle Attribute Readerで読み込みます。

Read Anchor InfoはScratch Pad Moduleですが、中身は以下のようになっていて、単純にAnchorで取得した、Start/EndPositionとStart/EndNormalを読み込みEmitterのアトリビュートに設定します。合わせてStart地点とEnd地点の距離もDistanceというアトリビュートを作り設定します。

また、雷全体のLifetimeをAnchorのEmitterで指定したかったので、そちらで設定したLifetimeも読み込んでいます。

これらの情報を使い、実際にParticleの配置を設定しているのがこちらです。まずは、RibbonLinkOrderの説明からしていきます。

RibbonLinkOrderは、Ribbon RendererがParticleを繋ぐ順を指定するアトリビュートになります。値は0~1の範囲で設定し、値が若いParticle順に繋ぎます。

ここでは、RibbonLinkOrderに、Return Normalized Exec Indexを指定しています。Normalizeが出てきましたね!

これは、NormalizeしたExec Indexを返すという意味になります。Exec Indexというのは、Spawn BurstでParticleを発生させた場合、各Particleの通し番号になります。5個発生させた場合は、各ParticleのExec Indexは、発生順に0,1,2,3,4の値になります。

このExec IndexをNormalizeするので、Normalized Exec Indexは、発生順に0~1の値になります。それをRibbon Link Orderに入れることで、Particleの発生順にRibbonとして繋ぐことができます。

これで、RibbonLinkOrderを介して、Particleに0~1のNoramlizeされた値を持たせることができました。

次にこれを使って、実際にRibbonを配置していきます。

Ribbonを配置する時に、対象のMeshから取得した2点を、単純に直線で結ぶだけではMeshにめり込んでしまいます。なので、Mesh上の2点は動かさず、間をめり込まないように膨らませるように配置します。

そうするために、Ribbon Link Orderからカーブを作成し、膨らませる大きさの係数を設定します。

RibbonOffsetBiasというアトリビュートを作り、CurveIndexにRibbonLinkOrderを設定してカーブを作成します。画像のように、RibbonLinkOrderが0と1の時に、カーブの値が0で、0.5の時に1になるような山なりのカーブを作ることで、Ribbonの両端の位置は動かさず、真ん中にかけてオフセットさせることができるようになります。

この膨らませる大きさの係数値とRibbonLinkOrder、Meshから取得したStart/EndPosition、Start/EndNormalを使って、実際にRibbonを配置します。以下がその処理です。

まずは、StartPositionとEndPositionをRibbonLinkOrderでLerpします。この処理だけで、StartPositionとEndPositionを直線で繋いだRibbonができます。

次に膨らませる方法ですが、StartNormalとEndNormalをRibbonLinkOrderでSlerpした値で方向を決め、それにPushという値を乗算してオフセット値出し、それを上記で算出した位置に足すことで、最終的に膨らんだ位置に配置できます。

LerpとSlerpの違いについて簡単に説明すると、Lerpは線形補間で、二つの値を真っすぐ繋いだ直線上の値を使いたい時に使用するもので、Slerpは球面線形補間で、主に二つの向きの間を回転させた向きを使いたい時に使用します。

今回、RibbonがMeshにめり込まないようにするためにする簡易的な方法として、開始点のMeshの法線方向から終了点のMeshの法線方向の間へ回転させた方向に膨らませるというやり方をしています。なのでSlerpを使ってRibbonLinkOrderで遷移させると上手くいきます。

最後に、どれだけ膨らませるかのPushの値は、以下のようにRibbonOrderBiasと膨らませる最大オフセット値を乗算する値を設定しています。

これで、任意のMeshの2点を結ぶRibbonができます。これが、このプロシージャル雷エフェクトの基礎の仕組み部分になります。

あとはこれをいい感じにNoiseをかけたり、動かしたり、Shaderを工夫したり、Particleを追加したりして雷表現にしていきますが、そこの解説は今回は割愛します。

まとめ

アーティストにとっては少し難しい内容だったかもしれませんが、特にVFXアーティストはNiagaraやHoudini、VFX Graphなど、エンジニアの手を借りなくてもアーティストだけでできることがどんどん増えてきています。

それらのツールを使ってより面白い表現を探求するとき、数学的な知識は必ず助けになるので、その第一歩としてNormalizeについて理解を深めてもらえると良いと思います!

今回は触れませんでしたが、ベクトルのNoramlizeもよく使うので、興味があれば深堀りしてみてください!

※途中のチャレンジ問題の解答は、Normalized Age = Age / Lifetime です!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です