この記事はHoudini Advent Calendar 2020の10日目の記事です。
初めに
この記事はMMDのファイルフォーマットであるpmxファイルをHoudini HDKで読み込んで遊んでいたものを記事にまとめたものです。
途中で満足してしまったのでpmxのファイルのデータを完全にサポートするものではありません。
Houdini HDKのとっかかりとしての具体的なサンプルとして見てもらえると幸いです。
またpmxファイル仕様については書いておりません。そちらに興味のある方はPMXエディタにドキュメントが含まれていますのでそちらを参照してください。
具体的に読み込めているものとしては
- ポリゴン情報
- 法線
- UV
- 後続のノードでマテリアルを設定するための諸々
となります。画像は原神のMMDモデルを使用しています。
コードはGitHubにありますので必要ならこちらを見てください。
実行環境
- Windows 10 Home 64bit
- Houdini 18.5.351
- Visual Studio Community 2019
- CMake 3.14.1
プロジェクトを作成する
Windows環境でVisual Studioで開発を行う場合にCMakeを使用するのが手数が少なくて楽かなと思います。
HDKのドキュメントCompiling with CMakeにサンプルとしてあるSOP_Starを改造していきます。
公式でSOP_Starをビルドする動画もあがっているのでこちらも参考になります。
https://www.sidefx.com/ja/tutorials/quick-tip-getting-started-with-the-hdk/
まずはリネームしていくだけです。そしてcmakeを実行します
mkdir build && cd build
cmake ../ -G "Visual Studio 16 2019" -A x64
成功すればVisual Studioのプロジェクトファイルが生成されます。
ノードのパラメータを設定する
HDKにはパラメータを設定を容易にするためにコードを自動生成する仕組みが用意されています。
その仕組みを設定しているのがCMakeLists.txtに記述したこの一文です。
houdini_generate_proto_headers( FILES SOP_PMXFile.C )
仕組みとしてはシンプルでビルド時に以下のpythonスクリプトを実行するようになります。
(Houdiniインストールディレクトリ)\houdini\python2.7libs\generate_proto.py
スクリプトの実行成功したらSOP_PMXFile.proto.hのヘッダーファイルが生成されます。
気をつけたいのがビルド時に実行するのでCMakeでVisual Studioのプロジェクトファイルを生成した段階ではまだSOP_PMXFile.proto.hは存在しません。
Visual Studio上からSOP_PMXFile.proto.h.ruleを右クリック⇒コンパイルでスクリプトを単体で実行できます。
ここでは以下の二つのパラメータを設定します。
- pmxファイルのパス
- スケール
generate_proto.pyが処理できるようにTHEDSFILEで囲って文字列で指定します。
static const char *theDsFile = R"THEDSFILE(
{
name parameters
parm {
name "file"
cppname "FilePath"
label "File"
type file
parmtag { "filechooser_pattern" "*.pmx" }
parmtag { "filechooser_mode" "read" }
}
parm {
name "scale"
label "Uniform Scale"
type float
size 1
default { "0.1" }
}
}
)THEDSFILE";
name, label等はHoudiniを触っていたら馴染みがあると思いますがparmtagの書き方は?ってなりました。画像の赤枠の部分です。
Houdini上で適当なノードにEdit Parameter Interface...からパラメータを追加して設定していくとTagsに項目が追加されていきます。ここでどんなTagsが追加されるかを確認してコードを書いていくのが良いかと思います。
ノードの処理を実装する
NodeVerbとは?
SOP_PMXFileVerb(元:SOP_StarVerb)に処理を書いていくことになります。NodeVerbとは何かというのはHoudiniのpythonのドキュメントに説明がありました。
プログラム的にVerb(動詞)を使ったジオメトリ
ジオメトリとパラメータを受け取ってジオメトリに変更を加える役目を持つクラスという認識で問題なさそうです。
以降では
void SOP_PMXFileVerb::cook(const SOP_NodeVerb::CookParms &cookparms) const
の関数でコーディングを進めていきます。
ジオメトリとパラメータの受け取り
/* 入力ジオメトリ */
GU_Detail *detail = cookparms.gdh().gdpNC();
/* パラメータ */
auto &&sopparms = cookparms.parms<SOP_PMXFileParms>();
// pmxファイルのパス
auto& filepath = sopparms.getFilePath();
// スケール
auto scale = sopparms.getScale();
ほぼサンプル(SOP_Star)どおりです。SOP_PMXFileParmsがgenerate_proto.pyで自動生成されたクラスです。
ファイルの読み込み
FS_Reader reader(filepath);
if (!reader.isGood())
{
return;
}
UT_IStream* stream = reader.getStream();
UT_WorkBuffer work_buffer;
stream->getAll(work_buffer);
ここではHDKのFS_Readerを使用してUT_WorkBufferにデータを読み込んでいます。
C++標準のファイル読み込み機能も使えるとは思いますが文字列のエンコーディング等の面倒事を避けるためHDKに含まれる機能は出来るだけ使用するようにしています。
PMXファイルのパース
詳細は割愛しますが以下のような情報を読み込みました。
BoneWeightInfo, MaterialはPMXに入っている情報を構造体にしたものです。
// 頂点位置の配列
UT_Array<UT_Vector3> position_array;
// 法線の配列
UT_Array<UT_Vector3> normal_array;
// UVの配列
UT_Array<UT_Vector2> uv_array;
// 頂点ウェイト情報の配列
UT_Array<BoneWeightInfo> bone_weight_array;
// 使用しているテクスチャの配列
UT_Array<UT_String> texture_array;
// マテリアル情報の配列
UT_Array<Material> mat_array;
// マテリアルが何ポリゴンずつ割り当てられているかという配列
UT_Array<exint> mat_index_count_array;
// インデックス配列 (何番目の頂点を使ってポリゴンを構成するかという情報)
UT_Array<int> indices;
PMXファイルはHoudiniのようなPoint, Vertexの区別はなく頂点情報としてまとまっています。
そのためposition_array, normal_array, uv_array, bone_weight_arrayはすべて同じサイズになります。
軸変換行列を作る
HoudiniとPMXでZ軸の向きが逆のようなので軸を変換するための行列を作成します。
また位置にスケールを適用する行列もここで作っておきます。
// 軸変換行列
UT_Matrix4 axis_mat(1.f);
axis_mat.scale(UT_Vector3(1.f, 1.f, -1.f));
// ポジション変換行列
UT_Matrix4 pos_conv_mat(axis_mat);
pos_conv_mat.scale(scale);
Pointの追加とPointアトリビュートの設定
前述したとおりPMXはPointとVertexの区別がないのでここでは法線, UVもPointアトリビュートで設定しています。
N,uv等のよく使用するアトリビュートは専用に追加関数が用意されているのでそれで簡単にアトリビュートを追加出来ます。
またHDKにはGA_Handleというアトリビュートに簡単にアクセスするための仕組みが用意されています。
// num_pointsの数だけポイントを作る
GA_Offset startptoff = detail->appendPointBlock(num_points);
// 各ポイントの位置を設定する
// PMXから読み込んだ情報に行列をかけて位置を調整する
for (exint point_idx = 0; point_idx < num_vertices; ++point_idx)
{
GA_Offset ptoff = startptoff + point_idx;
auto pos = position_array[point_idx] * pos_conv_mat;
detail->setPos3(ptoff, pos);
}
// 法線(N)アトリビュートを追加する
if (auto normal_attrib = detail->addNormalAttribute(GA_ATTRIB_POINT))
{
// 法線(N)アトリビュートの値を設定する
// 軸変換行列をかけて向きを補正する
GA_RWHandleV3 handle(normal_attrib);
for (exint point_idx = 0; point_idx < num_vertices; ++point_idx)
{
auto normal = normal_array[point_idx] * axis_mat;
handle.set(point_idx, normal);
}
}
// UVアトリビュートを追加する
if (auto uv_attrib = detail->addTextureAttribute(GA_ATTRIB_POINT))
{
// UVアトリビュートの値を設定する
GA_RWHandleV3 handle(uv_attrib);
for (exint point_idx = 0; point_idx < num_vertices; ++point_idx)
{
UT_Vector3 uv(uv_array[point_idx].x(), uv_array[point_idx].y(), 1.f);
handle.set(point_idx, uv);
}
}
ポリゴンを追加する
頂点配列とインデックス配列が準備できている場合はGA_PolyCountsを使うことで簡単にポリゴンを追加できます。
ここではPMXが全て三角ポリゴンで出来ているという前提でポリゴンを作成しています。
GA_PolyCounts poly_counts;
poly_counts.append(3, face_index_count/3); // 三角ポリゴンがインデックス配列の数/3だけ存在する
GEO_PrimPoly::buildBlock(detail, startptoff, num_points, poly_counts, indices.data(), true);
Primitiveにマテリアル番号のアトリビュートを追加する
HDKで関数が用意されていない独自のアトリビュートを追加する場合はAdd〇〇Tuple関数を呼びます。〇〇にはFloatやInt, Stringが入るので必要なアトリビュートに合わせて使い分けます。
// マテリアル番号を表すmat_indexアトリビュートを追加
if(auto mat_index_attrib = detail->addIntTuple(GA_ATTRIB_PRIMITIVE, "mat_index", 1))
{
GA_RWHandleI handle(mat_index_attrib);
exint prim_index = 0;
for (exint mat_index=0; mat_index<mat_index_count_array.size(); mat_index++)
{
exint mat_index_count = mat_index_count_array[mat_index];
for (exint i = 0; i < mat_index_count; i += 3)
{
handle.set(prim_index++, mat_index);
}
}
}
Detailアトリビュートにマテリアルを構築するための情報を追加する
ファイルパス
ファイルパスはテクスチャを相対パスから読み込むために使用します。
mat_indexと同じようにaddStringTupleを使用してHandleからアクセスするときにはGA_Offset(0)を使用します。
auto filepath_attrib = detail->addStringTuple(GA_ATTRIB_DETAIL, "filepath", 1);
GA_RWHandleS handle(filepath_attrib);
handle.set(GA_Offset(0), filepath.c_str());
テクスチャ配列
文字列の配列を作成する必要があるのでAddStringArrayを使用します。またアトリビュートにアクセスする方法も異なっています。
auto tex_names_attrib = detail->addStringArray(GA_ATTRIB_DETAIL, "tex_names");
if (const GA_AIFSharedStringArray* aif = tex_names_attrib->getAIFSharedStringArray())
{
UT_StringArray names_for_set;
names_for_set.setCapacity(texture_array.size());
for (auto& name : texture_array)
{
names_for_set.append(name);
}
aif->set(tex_names_attrib, GA_Offset(0), names_for_set);
}
マテリアル情報
ここでは辞書(ディクショナリ)型の配列を作成して情報を詰め込んでいきます。AddDictArrayでアトリビュートを追加します。
auto mat_parms_attrib = detail->addDictArray(GA_ATTRIB_DETAIL, "mat_parms");
if (const GA_AIFSharedDictArray* aif = mat_parms_attrib->getAIFSharedDictArray())
{
UT_Array<UT_OptionsHolder> json_values;
json_values.setCapacity(mat_array.size());
for (const Material& mat : mat_array)
{
UT_Options options(
"string name", mat.name.c_str(),
"string name_en", mat.name_en.c_str(),
"vector4 diffuse", mat.diffuse.x(), mat.diffuse.y(), mat.diffuse.z(), mat.diffuse.w(),
"vector3 specular", mat.specular.x(), mat.specular.y(), mat.specular.z(),
"vector3 ambient", mat.ambient.x(), mat.ambient.y(), mat.ambient.z(),
"int flag", int(mat.flag),
"vector4 edge_color", mat.edge_color.x(), mat.edge_color.y(), mat.edge_color.z(), mat.edge_color.w(),
"float edge_size", mat.edge_size,
"int tex_index", int(mat.texture_index),
"int sphere_tex_index", int(mat.sphere_texture_index),
"int sphere_mode", int(mat.sphere_mode),
"bool toon_flag", mat.shared_toon_flag != 0,
"int toon_tex_index", int(mat.toon_texture_index),
nullptr // 最後にはnullptrが必要
);
json_values.append(UT_OptionsHolder(&options));
}
aif->set(mat_parms_attrib, GA_Offset(0), json_values);
}
HoudiniのGeometry Spreadsheetでは以下のように表示されます。
ビルド・実行
CMakeで作成したプロジェクトではビルドに成功したらHoudiniで読みこめる場所に自動で配置されます。
$HOUDINI_USER_PREF_DIR/dso/SOP_PMXFile.dll
(C:\Users(ユーザー名)\Documents\houdini18.5\dso\SOP_PMXFile.dll)
この状態でHoudiniを起動するとTabメニューからPMX Fileノードを作成できるようになっています。
UVのY軸を反転
UVのY軸の向きが逆になっているのを後で気づいたのでPoint Wrangleで反転しました。
HDKの方でやってもよいと思います。
pythonスクリプトでマテリアルを設定
マテリアル情報を色々読み込んでいましたがLabs Quick Materialでベースカラーテクスチャしか設定していません。
- PMX Fileノード
- UVのY軸を反転のPoint Wrangle
- Labs Quick Material
の3つのノードをHDAとしてまとめてHDAにボタンを追加して以下のスクリプトを実行しています。
import os
def onUpdateMaterial(node):
quick_material = node.node('quickmaterial')
geo = node.geometry()
mat_parms = geo.dictListAttribValue('mat_parms')
tex_names = geo.stringListAttribValue('tex_names')
filepath = geo.stringAttribValue('filepath')
filedir = os.path.dirname(filepath.encode('utf-8'))
quick_material.parm('mMaterialEntries').set(len(mat_parms))
quick_material.hm().OnMultiparmChange(quick_material)
for i, mat_parm in enumerate(mat_parms):
quick_material.parm('groupselection_{}'.format(i+1)).set('@mat_index=={}'.format(i))
tex_index = mat_parm['tex_index']
texture_path = os.path.join( filedir, tex_names[tex_index].encode('utf-8')).replace(os.sep, '/')
quick_material.parm('basecolor_texture_{}'.format(i+1)).set(texture_path)
まとめ
今回はHDKを使用したノード作成例と解説を抜粋して記述しました。
実際のところはHDKを使うべき状況というのはほぼ発生しないかなーとは思っています。
しかしHDKが必要な状況になった場合は膨大なドキュメントやヘッダファイルから自分に必要な情報を探し出して検証するという作業をしなければならなくなるのでこの記事がそんな状況に陥った人の手助けになれば幸いです。