はじめに
Houdini 21からHDKにAPEX関連の機能群が追加されました。
これらによってC++を用いてカスタムのAPEXコールバックを追加できるようになりました。
サンプルとして$HFS/toolkit/samples/APEX/apex_external_test.Cも新たに追加されているためこちらをもとに構造を紐解いていきます。
環境
- Houdini 21.0.540
- AlmaLinux 9.6
- gcc 11.5.0
- CMake 3.26.5
- nushell 0.108.0
ビルド
$HFS/toolkit/samples/APEXをどこかしらにコピーします。
そのままビルドすると$HOUDINI_USER_PREF_DIR/apexdsoに共有ライブラリのリンクを作成するため$HSITE/houdini21.0/apexdsoにリンクを作成するようにCMakeLists.txtを変更します。
$HOUDINI_USER_PREF_DIR/*を汚さないようにする処置です。
問題ない場合はスキップしてください。
# apex_external_test plugin
#
cmake_minimum_required( VERSION 3.17 )
project( apex_external_libs )
# CMAKE_PREFIX_PATH must contain the path to the toolkit/cmake subdirectory of
# the Houdini installation. See the "Compiling with CMake" section of the HDK
# documentation for more details, which describes several options for
# specifying this path.
list( APPEND CMAKE_PREFIX_PATH $ENV{HFS}/toolkit/cmake )
# Locate Houdini's libraries and header files.
# Registers an imported library target named 'Houdini'.
find_package( Houdini REQUIRED )
# Add a library and its source files.
add_library(
apex_external_test SHARED
apex_external_test.C
)
# Link against the Houdini libraries, and add required include directories and
# compile definitions.
target_link_libraries( apex_external_test Houdini )
# Set common target properties for the plugin. Unlike regular HDK plugins, ones
# for APEX go into the apexdso subdirectory of HOUDINI_PATH.
#houdini_get_default_install_dir( output_dir )
#string( APPEND output_dir "/apexdso" )
set( output_dir "$ENV{HSITE}/houdini21.0/apexdso" ) # $HSITE/houdini21.0/に出力するように書き換え
houdini_configure_target(
apex_external_test
INSTDIR ${output_dir}
)
CMakeで$HFSおよび$HSITEの環境変数が必要になるためシェルで設定します。
今回$HSITEは~/Documents/houdini/hsiteにしました。
自分はシェルにnushellを使用しているため以下のコマンドで環境変数を設定しています。
$env.HFS = '/opt/hfs21.0.540'
$env.HSITE = '~/Documents/houdini/hsite'
サンプルのAPEXディレクトリに戻りビルドします。
cd APEX
mkdir build
cd build
cmake ..
make
途中でエラー等でなければこのシェルからHoudiniを開いてAPEX Network Viewを確認すると以下の3つのノードを作成することができます。
/opt/hfs21.0.540/bin/houdini
ソースコード
ソースコードを見ていきましょう。
以下にエクスポート関数部分を抜き出しています。
extern "C" APEX_VISIBILITY_EXPORT
void addApexFunction(APEX_Registry ®)
{
static MyCallback my_callback;
static SetPointTransforms set_point_transforms;
static GetPointTransforms get_point_transforms;
reg.addCallback(&my_callback);
reg.addCallback(&set_point_transforms);
reg.addCallback(&get_point_transforms);
}
見たところコールバックとクラスが1対1で対応していそうです。
それぞれのクラスの内部を確認するとコールバック名とポート名に一致する箇所があります。
static constexpr UT_StringLit funcname = "hdk::MyCallbackHDK";
static constexpr const char *argnames[] = {"det", "matrix"};
static constexpr UT_StringLit funcname = "hdk::SetPointTransformsHDK";
static constexpr const char *argnames[] = {"*geo", "transforms"};
static constexpr UT_StringLit funcname = "hdk::GetPointTransformsHDK";
static constexpr const char *argnames[] = {"transforms", "geo"};
これらのことからAPEX_GenericFunctionもしくはAPEX_GenericFunctionRunDataを継承したクラスを作成しfuncnameにコールバック名、argnamesにポート名を設定していそうです。
MyCallbackクラス
次はそれぞれのクラスにフォーカスしましょう。
構造が単純なMyCallbackクラスを下記に抜き出しました。
class MyCallback
: public APEX_GenericFunction<MyCallback, Float, const Matrix4>
{
public:
static constexpr UT_StringLit funcname = "hdk::MyCallbackHDK";
static constexpr const char *argnames[] = {"det", "matrix"};
void operator()(Float &det, const Matrix4 &m) const
{
det = m.determinant();
}
};
このクラスとノードを見比べると継承元クラスのテンプレートに
APEX_GenericFunction<継承先のクラス名、1番目の出力のポートの型、1番目の入力ポート型>
と指定しているのが確認できるかと思います。
テンプレート引数に対応するようにMyCallback::operator()がFloat& const Matrix4&を引数として受け取っています。
そして1番目の出力ポートの型は非const、1番目の入力ポートの型はconstです。
これらのことから
- コールバックの処理は
operator()で実行される - 入出力ポートの値どちらも参照引数で受け取り
constの有無で入出力を区別する - 出力の値は非
constの引数に値を書き込む
と考察できます。
クラスに手を加えて正しいか確認しましょう。
class MyCallback
: public APEX_GenericFunction<MyCallback, Float, const Matrix4, Float> // 最後にFloatを追加
{
public:
static constexpr UT_StringLit funcname = "hdk::MyCallbackHDK";
static constexpr const char *argnames[] = {"det", "matrix", "hoge"}; // 最後にhogeを追加
void operator()(Float &det, const Matrix4 &m, Float &hoge) const // 最後にhogeを追加
{
hoge = 0.0; // ヘッダーを見たところdouble型
det = m.determinant();
}
};
APEX_GenericFunctionのテンプレート、MyCallback::argnames、MyCallback::operator()の引数を変更しました。
ビルドしてノードを確認すると出力ポートにhogeが新たに追加されています。
GetPointTransformsクラス
一旦SetPointTransformsは飛ばしてGetPointTransformsについて見ていきましょう。
class GetPointTransforms : public APEX_GenericFunctionRunData<
GetPointTransforms,
VariadicArg<Matrix4>,
const Geometry>
{
public:
static constexpr UT_StringLit funcname = "hdk::GetPointTransformsHDK";
static constexpr const char *argnames[] = {"transforms", "geo"};
struct RunData
{
GA_OffsetArray myPointOffsets;
GA_DataId myNameDataId = GA_INVALID_DATAID;
bool needsRecache(const GU_Detail *gdp)
{
GA_ROHandleS name_h = gdp->findStringTuple(GA_ATTRIB_POINT, GA_Names::name);
if (name_h.isInvalid())
{
if (myNameDataId == GA_INVALID_DATAID)
return false;
myNameDataId = GA_INVALID_DATAID;
return true;
}
return name_h.getDataId() != myNameDataId;
}
void cacheLookup(const GU_Detail *gdp, const UT_StringArray &names)
{
myPointOffsets.clear();
myPointOffsets.setCapacity(names.size());
GA_ROHandleS name_h = gdp->findStringTuple(GA_ATTRIB_POINT, GA_Names::name);
if (name_h.isValid())
{
const GU_Detail::AttribSingleValueLookupTable *lut
= gdp->getSingleLookupTable(name_h.getAttribute());
for (auto &pt_name : names)
{
auto ptoff = lut->getStringOffset(pt_name);
myPointOffsets.append(ptoff);
}
myNameDataId = name_h->getDataId();
}
else
myNameDataId = GA_INVALID_DATAID;
}
};
APEX_DEF_TYPE_RUNDATA(GetPointTransforms)
void operator()(
RunData &rundata,
VariadicArg<Matrix4> &transforms,
const Geometry &geo) const
{
const GU_Detail *gdp = &*geo;
if (rundata.needsRecache(gdp))
rundata.cacheLookup(gdp, transforms.names());
int idx = 0;
GA_ROHandleM3D h_transform(gdp, GA_ATTRIB_POINT, "transform");
if (h_transform.isInvalid())
return;
for (auto &ptoff : rundata.myPointOffsets)
{
if (ptoff == GA_INVALID_OFFSET)
{
++idx;
continue;
}
Vector3 P = gdp->getPos3(ptoff);
Matrix3 m3 = h_transform.get(ptoff);
Matrix4 m = Matrix4(m3);
m.setTranslates(P);
UT_ASSERT(idx < transforms.size());
*transforms[idx++] = m;
}
}
};
コードから出力ポートと同名のtransformアトリビュートの値を取り出しポートに設定しているものだとということがわかります。
継承元クラスが先ほどと違ってAPEX_GenericFunctionRunDataになっています。
何が違うのでしょうか。
コードを眺めるとGetPointTransforms::operator()の第一引数にRunData &rundataというものが渡されています。
このRunDataはGetPointTransforms内部に定義されておりGetPointTransforms::operator()で呼び出されるRunData::needsRecache RunData::cacheLookupというメソッドを定義しています。
メソッドでは自身が持っているデータIDと渡されたジオメトリのデータIDを比較してポイントオフセットの配列を更新しています。
つまり、APEX_GenericFunctionRunDataを継承しRunDataを定義することで引数を介してキャッシュを保存できるようになるというわけです。
本当にキャッシュを持てるのか確認しましょう。
以下のようなクラスを作成しました。
コールバックが呼ばれたタイミングでただカウントアップするだけのコードになっています。
~~~
class MyCallbackRunData
: public APEX_GenericFunctionRunData<
MyCallbackRunData, Int, const Float>
{
public:
static constexpr UT_StringLit funcname = "hdk::MyCallbackRunDataHDK";
static constexpr const char *argnames[] = {"count", "hoge"};
struct RunData
{
RunData() = default;
~RunData() = default;
RunData(const RunData &other) = delete;
const RunData &operator=(const RunData &other) = delete;
exint _count = 0;
auto increment() -> exint {
return _count++;
}
};
APEX_DEF_TYPE_RUNDATA(MyCallbackRunData)
auto operator()(RunData &rundata, Int &count, const Float &hoge) const -> void {
count = rundata.increment();
}
};
~~~
extern "C" APEX_VISIBILITY_EXPORT
void addApexFunction(APEX_Registry ®)
{
static MyCallback my_callback;
static SetPointTransforms set_point_transforms;
static GetPointTransforms get_point_transforms;
static MyCallbackRunData my_callback_rundata{}; // 追加
reg.addCallback(&my_callback);
reg.addCallback(&set_point_transforms);
reg.addCallback(&get_point_transforms);
reg.addCallback(&my_callback_rundata); // 追加
}
APEXグラフやSOPネットワークを以下のように組みます。
hogeのスライダーの値を変えるとカウントアップしていきます。
正しくキャッシュで値を保持しているのがこれで確認できました。
SetPointTransformsクラス
最後にSetPointTransformsクラスです。
コードを見るとVariadicArg<Matrix4>のポート名と同名のポイントtransformアトリビュートを更新しているプログラムであるとわかります。
class SetPointTransforms : public APEX_GenericFunctionRunData<
SetPointTransforms,
Geometry,
const VariadicArg<Matrix4>>
{
public:
static constexpr UT_StringLit funcname = "hdk::SetPointTransformsHDK";
static constexpr const char *argnames[] = {"*geo", "transforms"};
struct RunData
{
RunData() = default;
~RunData() = default;
RunData(const RunData &other) = delete;
const RunData &operator=(const RunData &other) = delete;
GA_DataId myNameDataId = GA_INVALID_DATAID;
UT_Array<exint> myLookupHandle;
bool needsRecache(GU_Detail *gdp)
{
GA_ROHandleS name_h = gdp->findStringTuple(GA_ATTRIB_POINT, GA_Names::name);
if (name_h.isInvalid())
{
if (myNameDataId == GA_INVALID_DATAID)
return false;
myNameDataId = GA_INVALID_DATAID;
return true;
}
return name_h.getDataId() != myNameDataId;
}
void cacheLookup(GU_Detail *gdp, const UT_StringArray &names)
{
myLookupHandle.clear();
myLookupHandle.setSize(exint(gdp->getNumPointOffsets()));
myLookupHandle.constant(-1);
GA_ROHandleS name_h = gdp->findStringTuple(GA_ATTRIB_POINT, GA_Names::name);
if (name_h.isValid())
{
const GU_Detail::AttribSingleValueLookupTable *lut
= gdp->getSingleLookupTable(name_h.getAttribute());
exint idx = 0;
for (auto &pt_name : names)
{
auto ptoff = lut->getStringOffset(pt_name);
if (ptoff != GA_INVALID_OFFSET)
myLookupHandle[ptoff] = idx;
idx++;
}
myNameDataId = name_h->getDataId();
}
else
myNameDataId = GA_INVALID_DATAID;
}
};
APEX_DEF_TYPE_RUNDATA(SetPointTransforms)
void operator()(RunData &rundata, Geometry &geo, const VariadicArg<Matrix4> &transforms) const
{
GU_Detail *gdp = geo.gdp();
GA_RWHandleM3D transform_h(gdp, GA_ATTRIB_POINT, "transform");
if (transform_h.isInvalid())
return;
if (rundata.needsRecache(gdp))
rundata.cacheLookup(gdp, transforms.names());
if (rundata.myNameDataId == GA_INVALID_DATAID
|| rundata.myLookupHandle.size() < exint(gdp->getNumPointOffsets()))
return;
auto body = [&](const GA_Range &r)
{
for (auto ri = r.begin(); ri != r.end(); ++ri)
{
GA_Offset ptoff = *ri;
exint idx = rundata.myLookupHandle[ptoff];
if (idx < 0)
continue;
const Matrix4 &xform = *transforms[idx];
Vector3 pos;
xform.getTranslates(pos);
gdp->setPos3(ptoff, pos);
Matrix3 m3 = Matrix3(xform);
transform_h.set(ptoff, m3);
}
};
UTparallelFor(GA_SplittableRange(gdp->getPointRange()), body);
transform_h->bumpDataId();
gdp->getP()->bumpDataId();
gdp->incrementMetaCacheCount();
}
};
しかしノード側を見ると入力側に出力側として定義されているgeoポートがあります。
既存のtransformアトリビュートを上書きしているので当然ですね。
では一体どのようにして入力側にポートを出現させているのでしょうか。
argnamesに注目してみるとgeoの前に*が付いていることがわかります。
これが怪しそうです、新たにクラスを作成して試してみましょう。
~~~
class MyCallback2
: public APEX_GenericFunction<MyCallback2, Geometry, Geometry, const Geometry, const Float>
{
public:
static constexpr UT_StringLit funcname = "hdk::MyCallback2HDK";
static constexpr const char *argnames[] = {"geo0", "*geo1", "geo2", "number"}; // geo1に*をつける
auto operator()(Geometry &geo0,
Geometry &geo1,
const Geometry &geo2,
const Float &number) const -> void {}
};
~~~
extern "C" APEX_VISIBILITY_EXPORT
void addApexFunction(APEX_Registry ®)
{
static MyCallback my_callback;
static SetPointTransforms set_point_transforms;
static GetPointTransforms get_point_transforms;
static MyCallback2 my_callback2{}; // 追加
reg.addCallback(&my_callback);
reg.addCallback(&set_point_transforms);
reg.addCallback(&get_point_transforms);
reg.addCallback(&my_callback2); // 追加
}
このクラスをビルドしノードを作成すると以下のように*をつけたポート名が入出力ポートに現れています。
この*の有無で受け取ったGeometryに何かしらの処理をして出力するポートとGeometryを新規に生成して出力するポートを区別するということがわかります。
そしてこれこそがIn-Place Portの正体というわけです。
https://www.sidefx.com/docs/houdini/character/kinefx/apexgraphbasics.html
![]()
APEXが登場してすぐはこのIn-Place Portの前にValue<Geometry>を挿入しなければいけなかった理由はこのためです。
最新バージョンではIn-Place Portの前にValue<Geometry>がない場合でもあるかのような動作をするように変更されているため必須ではありません。
おわりに
HDKでカスタムのコールバックを作成できるようになったことで既存機能の組み合わせでできないこともC++で実装するという力技で対処可能となりAPEXの可能性が一段と広がったように感じます。
みなさんもHDKでカスタムコールバックを作ってみてください。
