【UE5】アウトラインを描いてみた オーバーレイマテリアル編 – Unreal Engine 5.4

Unreal Engine

水ノ茉の宣伝

準備中...
ゲームを作る予定なの
水ノ茉こおり

始まり

描画まわり初心者の頃に、一度は目にする記事じゃないかな。

【ShaderLab】常に同じ太さのアウトラインを作る
【ShaderLab】FoVによらずに一定の太さのアウトラインを作る

Unityでの内容ですが、当然、UnrealEngineでも流用可能です。

なんですが・・・

UnityとUnrealEngineのコンスタントバッファでは、各変数名が違います。

毎回、変数名を忘れてエンジンソースを覗いているんですが、地味に面倒なのです。

面倒だから、アーカイブなのです。

シンプルなアウトライン

ついでに背面法なアウトラインの実装方法を飽きるまでは細かく書いていきます。

マテリアルを作る

マテリアルを作成

Materialの頭文字を取ってM_Outline

パラメータ制御用にマテリアルインスタンスを作成

Material Instanceの頭文字を取ってMI_Outline

Unlit

輪郭線はライトを当てる必要がないのでShading ModelUnlitに変更します。

Two Sided

一般的に背面法の描画には前面カリングを使用します。
読んで字の如く、表面のメッシュをカリング、描画しないってことです。

Unityの場合はご存じの通りCull Frontを指定するだけです。
Unityちゃんは優しい子ですね。

対して問題児のUEさん。
背面カリング(Cull Back)カリングなし(Cull None)の2種類しか用意されていません。

そんなUEさんで前面カリングと同等のことをしたい場合は、大体3つのルートが用意されています。

  1. DCCツールで法線を反転させる
    • 背面法用にプリミティブコンポーネントが必要な点がネック
  2. カリングなしで描画
    裏表を符号で取得するノードを用いて、表面をマスク処理する
    • 当然負荷が高い
  3. エンジン改造してカリングを指定できるようにする(普段の筆者のやり方)
    • 商用ならこれ一択(知らんけど)

今回は2番目のカリングなしで描画して、表面をマスク処理する方法で進めていきます。

Two Sidedチェックを入れるとカリングなしで描画されます。

RenderDocなどのプロファイラで確認すると分かりやすいです。

Two Sided チェック なし
Two Sided チェック あり

Masked

表面をマスクしたいためBlend ModeMaskedにします。

ノードを組む

こんな感じのノードを組んでいきます。

Emissive Color

アウトラインのカラーを入力するところです。

VectorParameter

表示名をOutlineColorに変えてアウトラインカラーを指定します。

Opacity Mask

マスク値を入力するところです。
初期設定だと0.3333未満の値が入力された場合にマスク処理されます。

マスクやクリッピング、アルファマスクなど色々と名称がありますが、ここではOpacity Maskに倣ってマスクで表記を統一しておきます。違和感ある方は脳内フィルタを通して読んで下さい。

0.3333の謎

0.3333という基準値がどこから決められたかというとOpacity Mask Clip Valueです。

マスクの計算式はこのようになっており、結果が0.0以上の場合はマスクされず、0.0未満の場合にマスクされます。

clip(OpacityMask -  OpacityMaskClipValue);

マスクされる基準値を変えたい場合はこのあたりを調整してください。
ちなみに、標準のディザリングを使用するとマスク判定の計算がまた変わるので、この限りではなかったりします。

TwoSidedSign

前面なら1.0を、裏面なら-1.0を返すノードです。

TwoSidedSignに-1.0を乗算すると前面なら-1.0、裏面なら1.0になるので、前面がクリップされ、Cull Frontと同じ挙動になります。

World Position Offset (WPO)

読んで字の如く頂点オフセットの入力先です。

素直にWorldPosition + WorldPositionOffsetとして処理されるため、入力する際はワールド空間な座標を突っ込んで下さいね。

間違えると明らかに見た目が違うので分かりやすいとは思いますが。

VertexNormalWS

頂点法線が返ってくるノードです。

PixelNormalWSというノードもありますがUnlitでNormalピンを封印しているので気にしなくていいです。

ScalarParameter

表示名をThicknessに変えてアウトラインの太さを指定します。

オーバーレイマテリアルにアウトラインマテリアルをセットする

StaticMeshSkeletalMeshを配置して、Outlinerから選択してDetailsを開きます。
Rendering > Advanced > Overlay Materialに先ほど作成したMI_Outlineをセットすれば完了です。

完成!!!

品質や拡張性、処理負荷など色々と文句の付け所はありますが、UEでもUnityの下位互換程度の背面法はこんな感じで作れます。

ハードエッジな法線に切り替え

以降はシンプルな頂点法線ではなく、ハードエッジな法線を元にアウトラインを引いています。
毛先が割れなかったり、線が綺麗に出やすかったりする方法と思って頂ければです。

BeginPlayやConstructionのタイミングでスケルタルメッシュの頂点バッファを上書きしているだけなので、筆者にしては珍しくエンジン改造じゃないです。

実装方法は気が向いたら公開します。

距離で太さが変わらないようにする

さてと、ようやく本題に入ります。

カメラに近いと線が太く、カメラから遠いと線が細くなります。

距離に関係なく太さが一定になるようにしていきます。

ノードを組む

こんな感じのノードを組みます。

TransformVector (World Space to View Space)

参考元のUnityと使用している行列が違いますが気にしないで下さい。

UEのベクトル変換はTransformVectorノードに従っておけばいいのです。

座標変換はTransformノードの方を使ってくださいね。

カスタムノード

一度クリップ空間に変えて膨張方向を計算、その後にワールドに戻してオフセット量だけをリターンしている感じです。

float4 ClipPos = mul(float4(WorldPosition + LWCHackToFloat(ResolvedView.PreViewTranslation), 1.0), View.TranslatedWorldToClip);

float2 Offset = mul((float2x2)View.ViewToClip, Norm.xy);

ClipPos.xy += Offset * ClipPos.w * Thickness;

return mul(ClipPos, (View.ClipToTranslatedWorld)).xyz - LWCHackToFloat(ResolvedView.PreViewTranslation) - WorldPosition;

TranslatedWorldToClip

WorldToClip行列を使えばPreViewTranslationを考慮する必要はないのですが、UEではTranslationを含めた計算をするのが一般的です。

行列まわりはLWCが絡む関係でバージョンアップで型が変わったりするので、基本的にはTranslatedな方法を推奨します。

筆者も5.3までは他のエンジンや環境と同じようにTranslatedを含まない行列を多用していたのですが、5.4のアプデでLWCまわりにルート分岐(型追加)が追加されてムカついたので、Translatedな方法に乗り換えました。

完成!!!

カメラの距離に関係なく太さが一定になりました。

遠くの方が太く見えるのは錯覚です。
近距離での1ピクセルと遠距離での1ピクセルでは、画面に対する占有率が違うというアレです。

視野角(FOV)で太さが変わらないようにする

視野角が狭いと線が太く、視野角が広いと線が細くなります。

視野角に関係なく太さが一定になるようにしていきます。

カスタムノード

ノードの組み方は先ほどと同じで、コードを少し変えます。

要素のアクセスはView.ViewToClip._11でもView.ViewToClip[1][1]でも、好きな方を採用してください。

float4 ClipPos = mul(float4(WorldPosition + LWCHackToFloat(ResolvedView.PreViewTranslation), 1.0), View.TranslatedWorldToClip);

float2 Offset = mul((float2x2)View.ViewToClip, Norm.xy);

ClipPos.xy += Offset * ClipPos.w * Thickness / View.ViewToClip._11;

return mul(ClipPos, (View.ClipToTranslatedWorld)).xyz - LWCHackToFloat(ResolvedView.PreViewTranslation) - WorldPosition;

完成!!!

視野角に関係なく太さが一定になりました。

太さの単位をピクセルにする

筆者は背面法とポストプロセスアウトラインを併用することが多いです。

併用する上で太さの単位は規格を統一した方が扱いやすいので、ポスト側に合わせてピクセルに統一しちゃいます。

カスタムノード

ノードの組み方は先ほどと同じで、コードを少し変えます。

ViewSizeAndInvSizeは読んで字の如くxyにビューサイズ、zwにビューの1除算が格納されています。
ビューサイズであり、バッファサイズではないです。

バッファサイズが欲しい場合はBufferSizeAndInvSizeを使いましょうね。

ちなみにFOV補正前後のどちらに乗算しても構いません。
括弧も付けたきゃ付けろです。

float4 ClipPos = mul(float4(WorldPosition + LWCHackToFloat(ResolvedView.PreViewTranslation), 1.0), View.TranslatedWorldToClip);

float2 Offset = mul((float2x2)View.ViewToClip, Norm.xy);

ClipPos.xy += Offset * ClipPos.w * Thickness * View.ViewSizeAndInvSize.z / View.ViewToClip._11;

return mul(ClipPos, (View.ClipToTranslatedWorld)).xyz - LWCHackToFloat(ResolvedView.PreViewTranslation) - WorldPosition;

完成!!!

太さを1ピクセルにして距離と視野角の動作確認をするのが一番分かりやすいかもですね。

ポストプロセスアウトラインと併用するとこんな感じ。
エンジン改造によるアウトラインマスクを入れてないから余計なところに線が描かれるけど。

おわり!!!

ViewCBの変数名をメモするだけのつもりが思った以上に筆が乗ったわ。

アウトライン関連

読み上げツールの改修 第3弾ぐらい?

履歴を見る限り最後に手を加えてから5か月の月日が経過したようです。
セレオブの発売が7月だったので、それに合わせて急ピッチで大改修したのが最後ですね。

未完成ではありますが、お気に入りのツールです。

お気に入りではあるんですが、使用していく中でどうしても気になることが。

それは、キャラクター名と台詞のトランジションが独立している場合の動作の不安定さ。

筆者の開発環境はゲーム開発というより深層学習開発を想定して組んでいるのでそこそこな高スぺです。
とはいえ、エロゲを起動して、UEをビルドして、推論モデルをぶん回すと、流石に重い。

対策として推論の実行タイミングを「テキストの遷移が発生 → 遷移が落ち着いたら」に限定しています。

当初は良い発想と思いましたが、落ち着きの判定がかなりシビアになりました。

分かりやすい例として背景アニメーションが発生している最中は落ち着きもクソもないです。
そのため、直前フレームとの差分量の平均や中央値で落ち着きに幅を持たせました。
これで大体は解決しましたが、あるシーンだけはどうにもなりませんでした。

それが最初に書いた、キャラ名と台詞のトランジションが一致していない環境の不安定さです。
背景アニメーションが組み込まれているシーンでは、落ち着きに幅を持たせた関係でそれが顕著に発生します。
オトメきでは、キャラ名のフェードインが台詞より数フレーム送れるような魅せ方をします。

いつもなら「かわいい!」で脳死でニヤニヤしていますが、今回ばかりは技術的な問題です。
呑気に笑っていられない。とはいえ根本的な解決策が思い付かない。

・・・数日経過しました。

文字抽出の差分で判定すればいいのでは?

天才!

そうそう、エロゲ読み上げに特化したOCR、こんなフローなのです。

  1. 文字領域の検出(CRAFT)
  2. 文字抽出(CRAFTの設計がベース)
    • 背景と文字をセグメンテーション
  3. 画像分類(CoAtNet)
  4. 類似文字推定(DistilBERT)
    • 「工事」「エロ」「悪口」など、見た目が似ている文字たちの読みを推論

文字抽出の結果を元に差分を計算すれば諸々が解決するのではと、結果としては大成功でした。
CRAFTはVGGがバックボーンのため軽いですし、GhostModuleの組み込みで追加の軽量化もしてあります。
文字抽出もエンコード4層、デコード3層でセグメントの精度の割には軽い方です。

こんな感じで差分が無くなれば真っ黒になるので、その画面の継続時間が一定を超えたらOCRを実行な感じです。

ふふふ。これで快適なエロゲ環境にまた一歩近づいてしまいました。

あとはツールの続きを作ったり、ノートPCでエロゲできるようにサーバーレス推論の準備を進めたり、色々やりたいことは盛り沢山ですが、とりあえず、年末年始の環境は整いました。

筆者さん作品の影響を精神的に受けやすいのでディストピアな世界観は避けるようにしているんですが、年末年始なら病んでも業務に影響が出ないので楽しむとします。

心にダメージを負わせる系のルートが用意されていないことを願います。

いやまじで。

発売日に待機はしていたんですが、結局ワードプレスのお引越しに熱が入って放置プレイしちゃった。

気分屋さんだからね、仕方がないね。

ということで久々のエロゲなの。

VOICEPEAKの連携を諦めた話

先の読み上げにはMatcha-TTSやAmazon Pollyを使っています。

ふと、それ以外のサービスを使いたくなりました。

気分屋さんの出番です。

そんな時、最初に目に留まったのがVOICEPEAKでした。

・・・これが過ちでした。

コマンドプロンプトによる実行は遅い

検証段階で環境パス未開通のため、直接ディレクトリに移動してから実行しています。

起動に約5秒ほど要します。

モデルの読込にはある程度の時間が掛かるため、起動時間に関しては許容するべき問題ではあるんですが、問題は起動し続けることが出来ないことです。

つまりはひとつの台詞を読み上げる度に5秒待つ必要があります。

公式に問い合わせてみましたが・・・

UI操作しか想定してないよ♪
コマンドプロンプト機能の改善予定もないよ♪

―――とのことでした。

UI操作を前提とした開発、設計である以上、これは仕方がないことです。

ということで公式が対応してないなら非公式でやってやろうじゃないかと。

思い返すと、非公式新聞って響きが良いですね。

ウィンドウハンドルによる操作がそこまで出来ない

エロゲをスクリプトで操作する際にウィンドウハンドルから色々したことがあるので、それと同じ手順で攻めようと考えました。

なんですが・・・

ウィンドウハンドルをリストに起こしてもVOICEPEAKと記載されているものが少なく且つ、見つかったもののParentが(0)だったので、それ経由で隠された子たちを追うことも出来ませんでした。

埒が明かないのでInspectで確認した結果です。

こりゃフォーカス操作ぐらいしか出来ませんね・・・

UIAutomation(UIA)で操作は出来たが・・・

ということで更に攻めた方法、UIAutomation(UIA)を探ってみました。

あっさり見つかりましたが、同時に問題も見つかりました。

セキュリティの観点からか、許容されている操作が極端に制限されている点です。

まずValueControlが封じられているためSetterが使えません。

Legacyは2つほど解放されていたのでそれのセッタを使おうとしましたが、未実装とのエラーが吐かれました。

最終的にはフォーカスした後にctypes.windll.user32でキー入力をすることで任意の文字をセットできることが分かりましたが、フォーカスしないとダメな時点で不採用が決定しました。

ここまで分かればメモリが指している箇所で小細工できそうですが、そこまでしちゃうと、自分で定めているソフトウェアまわりの倫理観に違反するので、VOICEPEAKは諦めてアンスコして終了です。

使い方に拘りや運用環境が特殊である場合には、自作した方が適していることが改めて実感できました。

数万ほど無駄にした気分でしたが、簡易的な防御手順などを学べたので良しとします。

埋葬

フォーカスしてからキー入力する検証コードの埋葬です。

キー入力はctypes.windll.user32で適当にやってください。
適当に書きすぎて汚い、けどリファクタする気にもならないので諦め。

import comtypes.client
import comtypes 


if __name__ == "__main__":
    UIA = comtypes.client.GetModule('UIAutomationCore.dll')
    IUIAutomation = comtypes.client.CreateObject(UIA.CUIAutomation)

    root_element = IUIAutomation.GetRootElement()

    condition = IUIAutomation.CreatePropertyCondition(UIA.UIA_NamePropertyId, "Voicepeak")

    if (window_element:=root_element.FindFirst(UIA.TreeScope_Descendants, condition)):
        text_condition = IUIAutomation.CreatePropertyCondition(UIA.UIA_ControlTypePropertyId, UIA.UIA_TextControlTypeId)
        text_box_list = window_element.FindAll(UIA.TreeScope_Descendants, text_condition)

        for i in range(text_box_list.Length):
            text_box = text_box_list.GetElement(i)
            name = text_box.CurrentName
            if name == "":
                text_box.SetFocus()
                # SendKeys

JCBしか勝たん

自分の世界に引きこもっている筆者さんは外界の出来事に疎いのですが、クレカ事情に関してだけは決済画面でいやが応でも目に付きます。

えぇ。

くたばれと思いますわ。

VisaとMaster。

とは言いましたが、普段からJCBしか使っていないのであんまり分かっていなかったりします。

いつも通り気にせずFANZAとDLsiteにお金を落としていく一般ユーザーなのでした。