Substance Designer + UE4でWangのタイルを実装してみる

3/14に開催した第6回Substance Designerゆるゆる会のテーマは地面でした。
特に自然か人工かは指定していない、一般的にGroundとして扱われるもの全般ということでいろいろな地面を参加者の方が作っていらっしゃいました。
SDの会だってのにHoudiniの解説しかしない人が出てくるという珍事もありましたが、盛況に終わったのではないかと思います。

さて、そのHoudiniの解説しかしなかったちょっとあれな人は骨折して会場に来れなかったということでリモートで参加されていたのですが、もんしょさんの作ったものはブログで解説してくれるんですよね?とか言ってきたわけで。
解説予定もなかったのですが、そう言われたら解説するしかないやろってことで解説します。

私はSandy Groundという名前で一応砂地に石が混じってるような地面を作ったのですが、割と簡単に作ったもので特筆すべき部分はありません。

f:id:monsho:20200315201942p:plain

こんな感じ。

砂地の地面の部分は凹凸もほとんどなく、砂粒のノーマルと荒野っぽさを少し出すためにちょっとした段差が入ってる程度です。
もちろん石の部分は高さがありますが、埋まってる設定なのでちょっと頭を出してる程度。
石は最近追加された [Atlas Scatter] ノードを使っていますが、Atlasで作成した石は本当に適当に作りすぎてヤバいレベルです。
この辺はゆるゆる会で解説したのでここでは割愛します。

このマテリアルには実はちょっとした仕掛けが施されています。
それがタイトルにあるWangのタイルです。
Wangのタイルとはどういうものかというと…面倒なので以下のサイトを参照してください。

www.pathofexile.com

図にあるようにテクスチャを16分割し、縦の青いライン、横の青いライン、縦の赤いライン、横の赤いラインはすべて接続が可能にします。
タイルに1~16の番号を付けた場合、 1番タイルの右側は赤いラインなので、赤いラインを左側に持つタイル(4番とか9番とか)と接続できるわけです。
このようにタイルごとの接続情報を利用していくらでも置き換えができ、かつ無限とも言えるような接続パターンで接続していける、いわゆるタイリングパターンを見せないでタイリングができるというわけです。

実際にUE4でやってみたのがこちら。

f:id:monsho:20200315205025p:plain

左は普通のタイリング、右はWangタイルです。
左はタイリングがバレバレですが、右はある程度のパターンが見えるものの、明らかにタイリングパターンがわかりにくくなっています。

では、どのように作成しているかというと、上のサイトで使われているものをほぼそのまま踏襲しています。
ただしノーマルやラフネスなども同様にWangタイルを作成する方法で、上のサイトのようなベースカラーだけWangタイルをして、その結果からノーマルを求めるという形は採用していません。

縦/横の青/赤ラインの対応する部分はすべて同じ場所からテクスチャを持ってきます。
ただの長方形をそのまま割り当てるのは良くないので、接続部はノイズで歪ませたブレンドマスクを使ってブレンドしています。
また、四隅の部分はすべてのタイルで接続ができなければならないので四隅だけはすべての場所で同じ場所からテクスチャを持ってきてブレンドします。
こうすることでそれぞれのタイルの対応するラインが接続可能になります。

注意点としては、接続に使用するテクスチャはあまり特徴的すぎないほうがいいという点です。
あまりに特徴的すぎるとその特徴が縦横に連続することになるので割とバレます。
残念ながら今回作成したWangのタイル生成用グラフはそういう特徴が出ないように手で調整するという形を採用していますが、よりよい結果を求めるのであればDeep Learningなどを利用して自動的にいい感じの場所をいい感じにブレンドしてもらうようにすべきでしょう。
現在のSubstance Designerには出来ませんが、将来的にはSubstance Alchemistでできるようになるかもしれませんね。

UE4のマテリアルはこんな感じです。

f:id:monsho:20200315210811p:plain

Customノードを多用していたり、なにげにDetail Normalを使っていたりしますが、コピペすれば多分使えるはずです。
今回はタイルパターンが元のUV値に合わせて自動的に生成されるようになっていますが、自前のパターンテクスチャを使用することももちろん可能です。
その場合は接続が正しくなるようにパターンを生成する必要があるので注意してください。

また、この手法は普通にテクスチャサンプリングしてしまうと接続部分でミップレベルがおかしくなるという弱点がありますが、こちらもミップレベルを自前で計算するという手法で対応しています。
その関係でテクスチャのサイズを指定しなければならないのがちょっと面倒です。

以下はWangのタイルでリンク場所がバレバレな失敗例。

f:id:monsho:20200315212824p:plain

四角いタイル形状が非常にわかりやすいですね。
これは元マテリアルにPerlinノイズによる緩やかな傾斜を与えた場合の結果ですが、なぜこうなるかというとノーマルマップを見るとうっすらわかります。

f:id:monsho:20200315213221p:plain

緩やかな凹凸から生成されたノーマルが思いっきりずれてしまっていて、ノーマルの段階でタイルの接続部がバレるようになってしまっています。
今回のSandy Groundマテリアルはこのような問題に引っかからないように注意して作成されているのです。

というわけで適当ではありますが解説はこれで終了します。
マテリアルはTrelloの方で公開していますし、UE4のアセットも内包するReadmeにリンクを書いておいたので、そこからダウンロードできるようになっています。

trello.com

興味がありましたらDLして調べてみてください。

BlenderとDem Bonesでブレンドシェイプをボーンモーションにする

つい先日のことですが、Electronic ArtsさんがDem Bonesというライブラリを公開しました。
GitHub上で、BSD 3-Clauseライセンスとなっています。

github.com

ヘッダオンリーライブラリで、依存しているのもEigenとOpenMPくらいらしいので、自前ライブラリに組み込んだりDCCツールのプラグインを作ったりしやすいと思います。

GitHub上ではコードの他にサンプル的なコマンドラインツールも付属しているので、とりあえず試してみるということが簡単にできます。
というわけで、Blenderブレンドシェイプとそれを使ったアニメーションを作り、これをDem Bonesで変換するという一連の流れを試してみました。

Blenderでベースメッシュを作成する

まずはBlender上でベースメッシュを作成します。
といっても普通にメッシュを作るだけですが、その段階でブレンドシェイプも仕込んじゃいましょう。

とりあえずはスザンヌさんにご登場願い、これをベースメッシュとします。
そして、ブレンドシェイプを適当に追加。

f:id:monsho:20200130230228p:plain

追加したシェイプキーを選択した状態でEdit Modeで頂点を編集すればブレンドシェイプを作成できます。
編集後、Object ModeでValueの値をいじってみてブレンドシェイプが有効になっているかどうかチェックしましょう。

ここまでのデータをFBXで出力します。
ここで出力したFBXのベース形状がボーンを埋め込んだスキニングメッシュのベースポーズとなります。

出力時の注意ですが、FBX Export設定のScaleを0.01にしておきます。
BlenderでのFBX出力サイズとAlembic出力サイズがどうも異なっている (というか、Dem BonesがFBXの単位を考慮してない?) ようなので、これをやっておかないとDem Bonesで変換したFBXのモーションがおかしくなります。

f:id:monsho:20200130231244p:plain

ブレンドシェイプアニメーションを作ってAlembicを書き出す

次にブレンドシェイプを使ってアニメーションを作成します。
特に難しいことはなく、時間変化に合わせてシェイプキーのValueにキーを打つだけです。

f:id:monsho:20200130231211p:plain

満足したアニメーションが出来たらAlembic形式で出力します。
特に設定は必要ないので、ファイル名だけ指定して出力しましょう。

Dem Bonesで変換する

GitHubからDem Bonesをダウンロードしたら bin フォルダの中に DemBones.exe があることを確認します。
そしてコンソールウィンドウを出し (PowerShellでOKですし、batファイルを作ってもいい)、次のようにしてDem Bonesを実行します。

DemBones.exe -a="Alembic File.abc" -i="Base Mesh.fbx" -o="Skeletal Mesh.fbx" -b=n

オプションの指定方法は -x=~ で、ファイルパスを示す場合は "" で囲みます。
-a オプションは必須で、ブレンドシェイプアニメーションを施したAlembicファイルパスを指定します。
-iオプションも必須で、こちらがベースポーズを指定するFBXファイルとなります。
-oオプションも必須で、こちらがボーンを組み込んだ出力FBXファイルです。
-bオプションはボーンの組み込みが行われていないベースFBXを指定する場合は必須となり、ボーン数を指定します。
ベースポーズのFBXには予めボーンを仕込んでおくことも可能なようですが、これは1つのメッシュに複数のブレンドシェイプアニメーションを施す場合に使用するのではないかと思われます。

とりあえずこれでデータに問題がなければ-oオプションで指定したFBXファイルが出力されるので、Blenderで読み込んでみましょう。
Import設定でScaleを100倍するのを忘れずに。

f:id:monsho:20200130233139p:plain

こんな感じにボーンが埋め込まれてモーションしたら成功です。
出力時のScaleを設定しておかないと、モーションしたらメッシュがクシャッとなってしまうので注意しましょう。

というわけで、簡単なDem Bonesの使い方でした。

いいところにビス打ちをしようと思って失敗した話

先日のSubstance Designerゆるゆる会にて出た話。

機械とかで形状のコーナーにビス打ちやネジ止めしたい場合にSDだとどうやってプロシージャルにやるの?
チュートリアル動画だと普通に位置を目合わせして配置してるけど、スマートじゃないしそれならPhotoshopでいいんじゃないか?
Houdiniならできるよ!←このセリフが大量発生

で、できらぁー!

えっ?いい感じの場所に完全自動で?

そんな中、参加者の一人がいいアイデアを出されてました。
仕組みとしてはベベルなどを利用して形状のコーナーと思われる部分だけを検出して [Flood Fill Mapper] で形状を散らすという方法。
なるほど、たしかにこの方法ならなんとかなるか?
とはいえ、四角形はなんとかなったけど六角形とかだとうまくいかないなど、いろいろ問題もあるっぽい。

私の方でも同じ方法でなんとかならないかと調整してみたのですが、色んなパターンを網羅するのが難しかったので別の方法を考えることにしました。
とはいえ、基本はコーナーを検出、Flood Fillで領域作成、Flood Fill Mapperで形状をばらまくという手法を採っています。
ただ、すこし凝った作りをして、エラーを最小限にしようと試みた次第です。

Flood Fill Mapperとは

[Flood Fill Mapper] ノードは Flood Fill の情報を利用するノードで、求められたAABBの範囲に収まるように指定された形状を配置するノードです。
形状のコーナー部分を検出し、Flood Fill でAABBが配置できれば形状を撒くのは難しくないのでは?と思われるかもしれません。
しかし、Flood Fill Mapper は Flood Fill に通した元形状の範囲でしか画像が出てくれません。

どういうことかというと、下の図を見てください。

f:id:monsho:20190901161522p:plain

この図はある形状(白)と Flood Fill のAABB(緑)、Flood Fill Mapperによる合成(赤)を示したものです。
Flood Fill のAABBは図のように三角形を取り込むわけですが、Flood Fill Mapper によって形状を撒いた場合、その形状が表示されるのは元の三角形の白い部分のみとなります。
つまり、黒い部分には形状は表示されませんので、ビス穴のようなものをAABBに対して撒いても正常な形状にはなりません。
コーナーを角丸などを駆使して検出した場合、くの字型になりやすいわけですが、その場合はくの字の中心部分は隙間となってしまうのでビス穴が発生しづらくなるというわけです。

なので単純にコーナーを検出するだけではなく、コーナーをそれなりに適切に拡大していってビス穴を取り付けられる程度の大きさと中身が詰まったAABBを作成しなければならないというわけです。

コーナー検出

コーナー検出は [Bevel] や [Non Uniform Blur] を利用して角丸を作り、元の形状からの差分で検出を試みました。
しかしこれは四角形であれば割と悪くない結果をもたらすのですが、六角形のように角の角度が甘い部分では検出結果が長く伸びすぎてAABBの精度がいまいちでした。

そこでもう少し検出結果を良くするため、[Pixel Processor] を使ったレイマーチ的な手法を用いることにしました。
図のようにあるピクセルから8方向にレイを飛ばし、エッジ部分を検出したレイの本数を計数します。
このレイの本数がしきい値を超えるようならコーナーとして認識し、白を設定、そうでない場合はコーナーではないとして黒を設定します。
レイは8ステップで処理し、パラメータで指定可能な距離を最大距離としてチェックを行います。

レイ1本分の処理は Pixel Processor で以下のように実装しています。

f:id:monsho:20190901170849p:plain

これを角度を変えて8方向に対して処理しています。
コーナー検出の結果は以下のようになります。

f:id:monsho:20190901171205p:plain

左の入力画像に対して右の結果が求められます。
しかしながら、1種類では完全に検出できなかったので、実際には2種類の検出結果をそれぞれ [Blur HQ] + [Histogram Scan] で小さすぎるものや細くラインになってしまったものを除去しています。

コーナーを拡張

コーナーは検出できましたが、これだけではビス穴をつけられません。
ここからはコーナー部分の画像を拡張して、ビス穴を打てる程度の大きさを確保します。

拡大は単純な方法として [Distance] ノードを使用する方法がありますが、単純に適用してしまうと隣のアイランドに接触してしまったりします。
これではおかしな部分にビスが打たれてしまうので、拡大方法も少し考えないといけません。

今回適用したのは少しだけ拡大して元形状でマスクと言う手法を繰り返す方法です。
マスクは元の形状情報を利用します。形状からはみ出した部分を削除するためですね。
複数回繰り返すことで元形状の内部にのみコーナーマスクを広げるわけですが、広げすぎても問題がありますので回数は10回以内で選択できるようにしました。
このためのグラフ、CornerExpand グラフは以下のような実装となります。

f:id:monsho:20190901173123p:plain

終結

このようになりました。

f:id:monsho:20190901173556p:plainf:id:monsho:20190901173626p:plainf:id:monsho:20190901173651p:plain

四角、五角、六角ではパラメータさえきちんと調整すればうまくいきます。
しかし、このような形状の場合はうまくいきません。

f:id:monsho:20190901174227p:plain

このような形状の場合は丸の中心にビスを打ちたいと思うはずですが残念ながらうまくいきません。
このような場合は別途の手法を用いたほうがいいでしょうね。

というわけで、一定の成功はあったものの、最終的には失敗してると言えるような結果となりました。

Tile SamplerのVector Map Inputで正しい方向を向かない場合の対処法

第3回SubstanceDesignerゆるゆる会にて、Tile SamplerのVector Map Inputについての問題が提示されました。
ある形状のエッジ部分にエッジに垂直になるように形状を配置したいが、Tile SamplerのVector Map Inputだとたまにおかしな方向を向くというものでした。

Tile SamplerノードのVector Map Inputは、入力されたノーマルマップの勾配方向に向かって入力形状を回転する機能です。
これを利用すると、ある形状のワッペンのようなマテリアルを作成し、そのエッジ部分に縫い付けたような糸を想起されるような形状を作ることが出来ます。

では試してみましょう。

f:id:monsho:20190609010105p:plain

適当な形状を作り、これをワッペンの形状とします。
そのエッジを検出し、これをTile SamplerのMask Map Inputに入れます。
Tile Samplerの[Mask Map Threshold]を1.0にすれば与えられた形状の場所のみパターンが描画されます。

次にこのマスクにBlurを適用し、ノーマルマップを生成します。
これによりエッジに垂直な勾配が出来ますのでこれをVector Map Inputに接続します。
あとは[Vector Map Multiplier]のパラメータを1.0にすれば入力した細い線の形状がエッジに垂直に出てくるはずです。

その結果がこちら。

f:id:monsho:20190609011535p:plain

正しく出てる…ように見えて一部は正しく出ていません。
耳のあたりの一部の線がエッジに垂直ではなく、エッジに沿っているようになっているのが見受けられるかと思います。

このようになる理由はVector Map Inputに起因します。
Vector Map Inputの挙動は、入力したノーマルマップのRGの値から勾配の方向を調べ、アークタンジェントを使って回転角度を求めています。
この方法では正しく傾きが出ている部分はいいのですが、正しく出ていない部分、例えばノーマルの方向が上向きになっている部分では正しく勾配方向が求められないことになります。
そのような場合は向きがおかしくなってしまうわけで、これは計算上仕方がないです。

ではどうすればいいかというと、Mask Mapとして入力したものからノーマルを計算する必要は別にないわけです。
つまりこうすればいい。

f:id:monsho:20190609082200p:plain

Edge Detectする前のノードにEdge Detectをブレンドしてまずは膨らませます。
これでTile Samplerで配置される位置は全てカバーできます。

次にBevelで内側に破綻しない程度に大きく傾きを付けていきます。
これでエッジ部分には外向きの傾斜が出来ますので勾配方向はすべての場所においてエッジの外側に向きます。
これでほぼ完全に正しい方向に対応できるようになるはずです!

f:id:monsho:20190609082653p:plain

エッジに沿っていたものがなくなり、綺麗に並んでいるのがわかるでしょう。

この問題の対処方法は他にもいくつかノードの組み合わせがあるのですが、概ね”正しい方向を正しく向かせられるノーマルを作成する”という単純かつ正しい方法でほぼ全て解決できます。
逆に言えば、うまく行ってないのはノーマルが勾配方向を示すものとしては正しくなっていないからと言うことになります。
ただしパッと見ではその正しくなさがわかりにくいと思いますので、出来るだけ”正しくならざるを得ない”ようなノーマルをVector Map Inputに提供するようにすることが大事でしょう。

ハハッ

f:id:monsho:20190609085242p:plain

UE4のPythonを使ったFBXインポート

まだベータ版ではありますが、UE4Pythonが使用できるようになっています。
主な使いみちはEditorでの作業の簡略化でしょう。
多くのDCCツールがPythonを採用しているため、作業効率化を図りたい人には馴染み深い言語でもあります。

UE4ではUIを含めた拡張機能プラグインで作成することになっていましたが、Pythonの導入で簡単な作業であればプラグインを作らなくても実行できるようになりました。
簡単な作業、ちょっとしたことというのは開発中はとても多く、これらを効率化出来るかどうかはイテレーションの回数に影響します。
その点でPythonは覚えておいて損がない言語だと思いますので、非プログラマの方もぜひ触ってみてください。

さて、今回はそんなちょっとした機能としてFBXファイルのインポートをやってみます。
FBXのインポートは1つのファイルだけなら普通にエディタを使えばいいのですが、大量のFBXを一度にインポートしたい場合などはエディタでやるのは大変です。
また、インポート後にマテリアルを変更したり、テクスチャも一緒にインポートしたり、インポートしたテクスチャをマテリアルインスタンスに割り当てたりと、作業は地味ですが大量にあります。
多くの場合、これらの作業は決められたルールで行われているので自動化もしやすいはず。
しかし、プラグインを作るのは少々面倒で、もっと簡単に処理したいと思ってしまうでしょう。

そこでPythonの登場です!
今回のサンプルはPythonを使ってFBXをインポート、それぞれのマテリアルにマスターマテリアルを継承したマテリアルインスタンスを設定するというものです。
テクスチャのインポートや割当は行っていませんが、これも対応は難しくないと思います。ただ、今回はマテリアルインスタンスの設定までです。

最初にPythonを使える状態にします。
プロジェクトを開いたら[編集]メニューの[プラグイン]を選択し、プラグインウィンドウを表示します。
そして以下の2つのプラグインを有効にしましょう。

f:id:monsho:20190518121215p:plain

Pythonは言わずもがなですが、[Editor Scripting Utilities]も今回は使用します。
2つのプラグインをONにしたらエディタを再起動します。

再起動後はアウトプットログウィンドウのコマンド入力部分に以下のコマンドで.pyファイルを実行できます。

py ファイル名
例)
 py "D:\test.py"

とても簡単です。当然ですが、.pyファイルは自前で用意しましょう。
今回はサンプルとして以下のようなスクリプトを書いてみました。

import unreal

# FBXインポート時の設定
op = unreal.FbxImportUI()
op.import_materials = True # マテリアルもインポート
op.static_mesh_import_data.combine_meshes = True # メッシュを1つにまとめる

# FBXインポートのタスクを生成
task = unreal.AssetImportTask()
task.automated = True
task.destination_name = 'TestMesh' # UE4上のアセット名
task.destination_path = '/Game/Test/' # アセットを保存するフォルダ
task.filename = "D:/test.fbx" # 読み込みたいFBXファイル名を指定する
task.options = op

tasks = [task]

# タスクを実行
# FBXとマテリアルがインポートされる
atool = unreal.AssetToolsHelpers.get_asset_tools()
atool.import_asset_tasks(tasks)

# インポートされたマテリアルを削除して、同名のマテリアルインスタンスを作成
# このインスタンスをインポートしたメッシュに割り当てる
mesh_data = unreal.EditorAssetLibrary.find_asset_data(task.destination_path + task.destination_name)
master_mat = unreal.EditorAssetLibrary.find_asset_data('/Game/Test/M_Master_base')
mesh = mesh_data.get_asset()
for i in range(mesh.get_num_sections(0)):
    mat = mesh.get_material(i)
    mat_path = unreal.EditorAssetLibrary.get_path_name_for_loaded_asset(mat)
    mat_name = mat_path[mat_path.rfind('/') + 1:mat_path.rfind('.')]
    mat_path = mat_path[:mat_path.rfind('/') + 1]
    unreal.EditorAssetLibrary.delete_loaded_asset(mat)
    mat_inst = atool.create_asset(mat_name, mat_path, unreal.MaterialInstanceConstant, None)
    unreal.MaterialEditingLibrary.set_material_instance_parent(mat_inst, master_mat.get_asset())
    mesh.set_material(i, mat_inst)

やっていることは簡単です。少しずつ見ていきましょう。

import unreal

UE4上のPythonの機能を使う場合はこのモジュールをインポートします。

op = unreal.FbxImportUI()

メッシュインポート時の設定情報を作成します。これは unreal.FbxImportUI クラスで作成し、必要なデータを設定しましょう。

task = unreal.AssetImportTask()
# ry
tasks = [task]

アセットのインポートを行う際には unreal.AssetImportTask クラスを作成します。
インポートするファイル1つにつき1つのオブジェクを使います。

atool = unreal.AssetToolsHelpers.get_asset_tools()
atool.import_asset_tasks(tasks)

アセットのインポートは unreal.AssetTools を利用しますが、このオブジェクトは unreal.AssetToolsHelpers.get_asset_tools() 関数を用います。
リストの先頭から順番に処理されるので複数のタスクを用意しても構いませんが、今回は1つだけです。

mesh_data = unreal.EditorAssetLibrary.find_asset_data(task.destination_path + task.destination_name)

unreal.EditorAssetLibrary を使用する場合に Editor Scripting Utilities プラグインが必要になります。
find_asset_data() 関数は指定パスのアセットを検索して1つだけ返します。
返り値の AssetData オブジェクトから get_asset() 関数を使うと、実際のアセットへのアクセサーを取得できます。
その後の forループで回すのはメッシュのセクション数、すなわちマテリアル数となります。

    mat = mesh.get_material(i)
    mat_path = unreal.EditorAssetLibrary.get_path_name_for_loaded_asset(mat)
    mat_name = mat_path[mat_path.rfind('/') + 1:mat_path.rfind('.')]
    mat_path = mat_path[:mat_path.rfind('/') + 1]
    unreal.EditorAssetLibrary.delete_loaded_asset(mat)
    mat_inst = atool.create_asset(mat_name, mat_path, unreal.MaterialInstanceConstant, None)
    unreal.MaterialEditingLibrary.set_material_instance_parent(mat_inst, master_mat.get_asset())
    mesh.set_material(i, mat_inst)

for文の中身です。
まず、マテリアルをインポートしているので、FBX内のマテリアル名と同一名のマテリアルアセットが作成されています。
マテリアルスロットに割り当てられているマテリアルからアセットパスを取得、これをパスとアセット名に分けています。

そしておもむろにマテリアルを削除します。同名のマテリアルインスタンスを作成するためです。
マテリアルインスタンスは create_asset() 関数で MaterialInstanceConstant を作成します。
このクラスのオブジェクトがコンテンツブラウザ上でのマテリアルインスタンスです。

このマテリアルインスタンスの親マテリアルとして取得済みのマスターマテリアルを設定します。
マテリアル名に何らかの識別子を用意し、名前に応じて使用するマスターマテリアルを変更させる、なんていうのも作業効率化になりそうですね。
親マテリアルを設定したらメッシュの対象番号のマテリアルとして設定します。

これだけ。 ちなみに、この状態だとアセットの保存ができてませんが、これも EditorAssetLibrary に存在しますので、メッシュとマテリアルインスタンスを保存すると良いでしょう。
ソースコード中のFBXファイル名を正しいファイル名に変更すれば動作するはずです。(UE4.22.1)

UE4Pythonは2.7だそうです。3系ではないので記述方法やモジュールに注意が必要です。
しかもUE4自身がPythonの実行ファイルを持っているので、必要なモジュールがない場合のインストールには注意が必要です。

とまあ、こんな感じです。
あとは自前のルールに合わせてコードを書き換えれば一括インポートは簡単になるのではないかと思います。

Substance Automation Toolkitによる簡単自動化

先日、Substanceゆるゆる会にて某氏から聞かれた話。
Substance Designerでテンプレート的なグラフを作成し、特定フォルダ内のすべてのテクスチャに対してそのグラフを適用してテクスチャを生成したいんだけど、できませんか?と。

で、できらぁ!

ということで、Substance Automation Toolkitで上のようなことをやる方法について書いてみます。
今回のサンプルには以下の環境が必要です。

さて、既存のテクスチャに何らかの処理を行いたいという話は割とよくある話だと思いますが、Substanceらしいこんな話をでっち上げてみましょう。

あなたはあるCG映像会社の社員です。
ある日、今やるべきことは流行りのリアルタイムレンダリングだ!Unreal Engine 4で昔作ったムービーをリアルタイムレンダリングするぞ!と社長に言われました。

そこでまず昔のデータをUE4に乗っけてみたところ、ノーマルマップもない状態だしPBR対応もしてないしでモデルだけだしても結果はよくありません。
とはいえ期間もないので全部作り直すのは不可能。多くのリソース、特に背景は昔のものを手続き的にブラッシュアップするだけにとどめたい。
そこでSubstance Designerを使って昔のテクスチャを手続き的に処理、ノーマルやラフネスを出力すればなんとかなりそうだというところまでは検証できました。

しかしリソースは大量で、すべてのテクスチャに対してSDでグラフ作成→テクスチャ出力なんてやってられない。
どうにか自動化出来ないだろうか?

はい、身につまされた人はいませんね?
え、いますか?
大変ですね。

すでにこんな経験をしている人ならすでに自動化してるとは思いますが、今後こういう事態に陥る人向けにこの記事を書きます。

まずはSDでテンプレートとなるグラフを作成します。
これにはまず適当なテクスチャ相手に問題なく動作するグラフを作成してください。
作成できたら入力しているテクスチャを入力ノードに変換します。
最終的に作成されるグラフはこんなものになるはずです。

f:id:monsho:20190307161025p:plain
テンプレートグラフ
今回はサンプルとしてB2M LiteでDiffuseテクスチャから各マップを出力するだけのものを作成しています。
もちろんこれは一例に過ぎないので、自分たちに必要な形でテンプレートグラフを作成してください。

次にSubstance Automation Toolkitですが、これを利用する方法は2種類あります。
1つはBatchtoolsを使用する方法で、インストールフォルダ内にコンソールアプリケーションがいくつか存在していますのでこれを使います。
直接使用できるので、ぶっちゃけバッチファイルを書くだけでも使えます。

もう1つはPythonを使う方法です。
といっても、実際にはPythonを通してBatchtoolsを使用するだけなので実はやってることは同じだったりします。
今回はこちらのPythonを通してやる方法と採用しています。

まず、Substance Automation ToolkitのPython APIをインストールします。
(インストールフォルダ)/Python API/Pysbs-2018.3.0_install.bat を叩いてPython APIをインストールしてください。
Pythonがインストールされてパスが通っていれば正常にインストールされるはずです。
MacLinuxについてはよくわからないですが、pip使えれば大丈夫じゃないかと。

次にテキストエディタでコードを書きます。
今回のコードは全文をここに書いておきますが、今後Substance系のコードが増えたらGitHubリポジトリ作るかもです。

import os
import sys
import subprocess
import pysbs
from pysbs import batchtools
from pysbs import context

if __name__ == "__main__":
    aContext = context.Context()
    template_path = 'MyDocument/Allegorithmic/Substance Automation Toolkit/specialize_test/B2MTest.sbs'

    # generate specialized sbs.
    output_path = 'MyDocument/Allegorithmic/Substance Automation Toolkit/specialize_test/'
    output_name = 'spec_test'
    base_color_image = 'MyDocument/Allegorithmic/Substance Automation Toolkit/specialize_test/Bricks_Test_basecolor.tga'
    metallic_image = 'MyDocument/Allegorithmic/Substance Automation Toolkit/specialize_test/Bricks_Test_metallic.tga'
    base_color_image_connect = 'input_diffuse@path@' + base_color_image + '@format@JPEG'
    metallic_image_connect = 'input_metallic@path@' + metallic_image + '@format@JPEG'
    proc = batchtools.sbsmutator_edit(
        input=template_path,
        presets_path=aContext.getDefaultPackagePath(),
        output_name=output_name,
        output_path=output_path,
        connect_image=(base_color_image_connect, metallic_image_connect),
        stderr=subprocess.PIPE)
    (out, err) = proc.communicate()
    proc.wait()
    if err:
        print(err)
        sys.exit(1)

    # cook sbsar.
    proc = batchtools.sbscooker(
        inputs=os.path.join(output_path, output_name) + '.sbs',
        includes=aContext.getDefaultPackagePath(),
        size_limit=13,
        output_path=output_path)
    (out, err) = proc.communicate()
    proc.wait()
    if err:
        print(err)
        sys.exit(1)

    # render textures.
    output_size = 11
    proc = batchtools.sbsrender_render(
        inputs=os.path.join(output_path, output_name) + '.sbsar',
        output_name='{inputName}_{outputNodeName}',
        output_path=output_path,
        output_format='tga',
        set_value=('$outputsize@%s,%s' % (output_size,output_size)))
    (out, err) = proc.communicate()
    proc.wait()
    if err:
        print(err)
        sys.exit(1)

フォルダ名やファイル名は適当ですので、必要に応じて変換してください。
また、今回のサンプルは1つの特殊化しかしていません。フォルダ内のすべてのテクスチャ、という感じにする場合は別途ファイル列挙などが必要になります。

簡単に解説していきます。
まず、インストールしたSubstancePython APIをインポートします。

import os
import sys
import subprocess
import pysbs
from pysbs import batchtools
from pysbs import context

今回はbatchtoolsとcontextだけ利用します。

その後はSubstanceのコンテキストを取得します。
コンテキストはSubstancePython APIを使用する上で基本となるものですが、今回はライブラリフォルダを取得するためだけに使用します。

if __name__ == "__main__":
    aContext = context.Context()

最初に実行するツールはsbsmutatorというツールです。
このツールは.sbsファイルの情報を取得したり、パラメータ等を変更して特殊化したりするためのツールです。
このツールを使うことで、テンプレートグラフの入力イメージを変更した特殊なグラフを作成し、.sbsファイルとして保存します。
そのためには batchtools.sbsmutator_edit() 命令を使用します。
なお、batchtools以下の命令はほぼ全てsubprocess.Popen命令の戻り値を返します。

    proc = batchtools.sbsmutator_edit(
        input=template_path,
        presets_path=aContext.getDefaultPackagePath(),
        output_name=output_name,
        output_path=output_path,
        connect_image=(base_color_image_connect, metallic_image_connect),
        stderr=subprocess.PIPE)
    (out, err) = proc.communicate()
    proc.wait()

コンソールアプリケーションであるsbsmutatorへの引数は、通常 "--" で始まるオプションを利用します。
例えば、入力イメージとしてファイルを指定する場合、"--connect-image" というオプションを利用します。
しかし、これはPythonの変数としては使用できない文字列ですので、上記の例の場合は "connect_image" を指定することになります。
先頭の "--" は削除、途中にある "-" は "_" に変更します。

また、複数の入力を指定する場合もあります。
"--connect-image" はオプション1つにおいて1つの入力を指定することになりますので、コンソールコマンドとしては以下のように指定することになります。

sbsmutator.exe --connect-image "input0@path@basecolor.png" --connect-image "input1@path@metallic.png"

Pythonの可変個引数で辞書を用いる場合、同じ引数名を複数指定はできませんので、1つの引数に対してタプルで複数指定すればOKです。

この命令で特殊化された.sbsファイルが出力されますので、今度はこれを.sbsarファイルに変換します。
このためにはやはりBatchtoolsのsbscookerを使用します。

    proc = batchtools.sbscooker(
        inputs=os.path.join(output_path, output_name) + '.sbs',
        includes=aContext.getDefaultPackagePath(),
        size_limit=13,
        output_path=output_path)

指定したパスに同名の.sbsarファイルが出力されます。
ちなみに、自分の環境ではちょっとした警告が出ていましたが、まあ特に問題はないんじゃないかなと思います。

最後に.sbsarファイルからテクスチャを出力します。
これにはsbsrendererを利用します。

    output_size = 11
    proc = batchtools.sbsrender_render(
        inputs=os.path.join(output_path, output_name) + '.sbsar',
        output_name='{inputName}_{outputNodeName}',
        output_path=output_path,
        output_format='tga',
        set_value=('$outputsize@%s,%s' % (output_size,output_size)))
    (out, err) = proc.communicate()
    proc.wait()

"--output-name" オプションにはSDで指定できるグラフ名、出力ノード名などが使用できます。
今回のサンプルでは使用する.sbsarファイルのファイル名と出力ノード名を利用していますが、もちろん固定の名前も可能です。
ただ、複数のイメージを出力する場合は出力ノード名は使うようにしましょう。名前がかぶります。

Substanceに渡す各種サイズですが、これは2のn乗のnの値を指定するようにしてください。
サンプルで使っている 11 なら2048になります。
この値に 2048 を直接指定したらひどい目にあいました。
画像ビューアで開けないサイズのテクスチャが出来たのですが、むしろレンダリングしてくれるんだなぁと感心してしまいました。

以上で一連の流れは完了です。
今回提示したソースコードはあくまでの一例でしかありませんし、実際の業務ではファイル名やフォルダ名を指定したり、列挙したファイルに対して処理をしたり、出力フォルダを作成したりといった処理が必要になります。
そのあたりも考慮に入れて参考にしていただけたらと思います。

Batchtoolsを使うと他にも.fbxからジオメトリ関連のマップ(ノーマルやAO、IDマップなど)をベイクし、IDマップに従って特定のマテリアルを割り当てるとかも可能です。
この手の自動化はうまくやれればかなりの効率化が可能ですので、是非いろいろ試してみてください。
そして、うちはこういう使い方したよ!みたいな情報があったらこっそり教えてくださいw

Substance DesignerのPythonプラグインの基本

東ゲ部もくもく会で調べた内容と、非常に簡単なプラグインの実装についてです。

Substance DesignerのPythonプラグインは去年の夏のアップデートだかで入っていたのですが、この段階では残念ながら現在編集中のグラフに対してあれやこれや出来ることが少なかったですね。
出来ることというと、グラフの再計算、ノードの整列、編集しているグラフとは別のグラフを新規作成して中身を作成とか、まあ、そんな感じでした。

しかし、年末のアップデートでいろいろと機能が追加されているというアップデート情報がありました。
その調査をしていなかったので、重い腰を上げて調査した次第です。

プラグイン作成方法

プラグインの作成方法は簡単です。
まず、メインメニューの[ウィンドウ]→[Python Editor]を選択してエディタを立ち上げます。
エディタ内メニューの[File]→[New Plugin]を選択し、ウィザードに従ってフォルダ名やプラグイン名を設定して[OK]を押すだけです。

この段階でプラグインは読み込まれ、メインメニューの[Scripts]の下に作成した名前のプラグインが追加されていることでしょう。

プラグイン配置

最初に作成したプラグインは前述の通りにメインメニューの[Scripts]直下に配置されます。
しかし、すべてのプラグインを直下に置くのは整理しづらいので困りますね。
そんな場合は以下の方法で別の場所に配置することが出来ます。

コード内のプラグインを作成しているコード部分に、sdplugins.PluginDesc() という関数があります。
この関数の引数である aPluginLocation を変更することで配置場所を変更可能です。

[Scripts]メニュー以下にフォルダを作ってその中に配置
aPluginLocation=sdplugins.PluginLocationMenu(sdplugins.MenuId.Scripts, 'MyPlugins', 0)

この例では [MyPlugins] フォルダを作成して、その中に配置します。
f:id:monsho:20190114162605p:plain

メインツールバー
aPluginLocation=sdplugins.PluginLocationToolbar(sdplugins.ToolBarId.Main, 0)

f:id:monsho:20190114162725p:plain

グラフエディタツールバー内
aPluginLocation=sdplugins.PluginLocationToolbar(sdplugins.ToolBarId.SBSCompGraph, 0)

f:id:monsho:20190114162813p:plain

ちなみにですが、aIconFileAbsPath にアイコン画像のファイルのフルパスを入力するとアイコン画像を変更することが出来ます。

現在編集中のグラフ、及び選択中のノードの取得

ここから実際のコードになります。
処理コードを書く場合は、ウィザードで作成した init.py の "# Put your code here" とコメントされている部分に書いていきましょう。

また、APIリファレンスは [ヘルプ]→[Python API Documentation] から参照できます。

aLocCont = aContext.getSDApplication().getLocationContext()
aSelectNodes = aLocCont.getSelectedNodes()
aGraph = aLocCont.getCurrentGraph()

SDLocationContext を取得後、そこから getSelectedNodes() 命令で選択中のすべてのノードを、getCurrentGraph() 命令で編集中のグラフを取得します。

ノードの追加

ノードをグラフに追加する場合、アトミックノードを追加するかグラフノード(.sbsで作成されたノード)を追加するかで作成方法が異なります。

アトミックノードの追加
aUniformNode = aGraph.newNode('sbs::compositing::uniform')

アトミックノードを新規作成するのは簡単です。
ノードの名前は sbs::compositing::~ で ~ 部分についてはAPIリファレンスを参照してください。

グラフノードの追加
aPackageMgr = aContext.getSDApplication().getPackageMgr()
aPackage = aPackageMgr.loadUserPackage('D:/test/SubstanceAdventCalender.sbs')
aCompNode = aGraph.newInstanceNode(aPackage.findResourceFromUrl('Day1_WoolKnit'))

こちらはちょっと面倒です。
まず、SDApplication クラスから SDPackageMgr を取得します。

グラフノードを作成するには、このパッケージマネージャに.sbsファイルを管理させなければなりません。
そのため、loadUserPackage() 命令で.sbsファイルをロードさせます。ファイル名はフルパスで指定しなければいけません。

パッケージの読み込みに成功したら findResourceFromUrl() 命令で追加したいグラフ名を指定します。

この手法は自前のグラフノードだけでなく、Substance Designer標準のグラフノードでも同じ作法が必要のようです。

ノードのプロパティを変更する

aPPNode = aGraph.newNode('sbs::compositing::pixelprocessor')
aPPNode.setInputPropertyInheritanceMethodFromId('$tiling', SDPropertyInheritanceMethod.Absolute)
aPPNode.setInputPropertyValueFromId('$tiling', SDValueEnum.sFromValueId('sbs::compositing::tiling', 'no_tiling'))
aPPNode.setInputPropertyValueFromId('colorswitch', SDValueBool.sNew(False))

ノードのプロパティを変更するには setInputPropertyValueFromId()setPropertyValue() を使います。
前者はプロパティの名前から値を設定、後者はプロパティオブジェクトに直接設定です。

プロパティオブジェクトは生成時に取得するか、getProperties()getPropertyFromId() で取得できます。

また、一部の標準プロパティは親ノードやグラフの設定を受け継ぎますので、その場合は設定が無効になるものも多いです。
そのようなプロパティは setInputPropertyInheritanceMethodFromId() 命令で継承方法を変更することが可能です。

ノードを接続する

aUniformOutput = aUniformNode.getProperties(SDPropertyCategory.Output)[0]
aUniformNode.newPropertyConnectionFromId(aUniformOutput.getId(), aPPNode, 'input.connector')

ノードの接続は newPropertyConnectionFromId() 命令か、newPropertyConnection() を使います。
これらの命令も前者はID指定、後者はプロパティオブジェクト指定です。

接続する場合は、接続元のノードに対して命令を発行し、引数として、出力ピンのプロパティ、接続先ノード、接続先入力ピンのプロパティとなります。

グラフにプロパティを追加して、値を設定する

aProp = aGraph.getPropertyFromId("test_value", SDPropertyCategory.Input)
if aProp is not None:
    aGraph.deleteProperty(aProp)
aProp = aGraph.newProperty("test_value", SDTypeInt.sNew(), SDPropertyCategory.Input)
aGraph.setPropertyValue(aProp, SDValueInt.sNew(0))

newProperty() 命令によって入力パラメータを追加できます。
setPropertyValue() はデフォルト値を設定することが出来ます。

ただ、何も考えずにプロパティを追加してしまうと、同一名のプロパティが存在した場合に自動的に末尾に番号をつけられます。
そのため、上記の例では deleteProperty() 命令で最初に同名のプロパティを削除しています。

サンプル

        # Put your code here
        selNodes = aContext.getSDApplication().getLocationContext().getSelectedNodes()
        aGraph = aContext.getSDApplication().getLocationContext().getCurrentGraph()
        for n in selNodes:
            if n.getDefinition().getId() == 'sbs::compositing::blend':
                srcProp = n.getPropertyFromId('source.connector', SDPropertyCategory.Input)
                dstProp = n.getPropertyFromId('destination.connector', SDPropertyCategory.Input)
                srcCnts = n.getPropertyConnections(srcProp)
                dstCnts = n.getPropertyConnections(dstProp)
                if srcCnts.getSize() > 0 and dstCnts.getSize() > 0:
                    n0 = srcCnts[0].getInputPropertyNode()
                    p0 = srcCnts[0].getInputProperty()
                    n1 = dstCnts[0].getInputPropertyNode()
                    p1 = dstCnts[0].getInputProperty()
                    n.deletePropertyConnections(srcProp)
                    n.deletePropertyConnections(dstProp)
                    n0.newPropertyConnection(p0, n, dstProp)
                    n1.newPropertyConnection(p1, n, srcProp)
        pass

とりあえず run() 関数の内部だけをコピペしました。
どんなプラグインかというと、選択中のBlendノードでForegroundとBackgroundのピンの入力を逆転するだけです。
ForegroundをBackgroundに、BackgroundをForegroundにします。
ボタン1つで簡単に切り替えられる便利機能!というほど便利でもないですが、とりあえずの練習用ということで。

今後はPixelProcessorの関数をPythonで書いてみたいですね。
ノードで組むの面倒なので…