HoudiniでSketchUp風のスケッチスタイルレンダリング

この記事は[Houdiniアドベントカレンダー2023](https://qiita.com/advent-calendar/2023/houdini)  22日目の記事です

# はじめに
[Procedural Design](https://www.procedural-design.com/)の[Akira Saito](https://twitter.com/a_saito)です。
Houdini等を使用しロボットなどのメカデザインを自動で行う研究をしています。
![image](https://pbs.twimg.com/media/GA9-UeBbUAA4fSH?format=jpg&name=large)


今回はHoudini20でSketchUpの様なスケッチスタイルレンダリングを行う方法を紹介します。

### 動作環境
Houdini Indy Version 20.0.551(apple silicon)
macOS Sonoma バージョン14.12
MacBook Pro (16-inch, 2021)
プロセッサ Apple M1 Max
メモリ 64 GB

# Sketch Upとは？
 元々は、Last Softwareが開発と販売を行なっていた建築系3Dデザインツールです。途中Googlに買収されたりと色々ありましたが、近年は米Trimbleが開発、提供しています。
 
建築向けに作られだツールですが、CADとしての精度、ブーリアンや押し出しの使い勝手の良さなど、魅力的な機能が多く、私もメカデザイン(モデリング)で度々使用していました。そして、今回Houdiniで実装しようとする、スケッチスタイルのビューポートレンダリングも大きな魅力の一つです。

デフォルトのSketchUpのレンダリング。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/2031fbb6-3663-b218-b617-ff452d8d4216.png)
スケッチスタイルのレンダリング
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/0fa15248-e354-2e08-8984-9a76115ec1f8.png)

# SketchUpの作例
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/264b6c05-d994-4c7e-ea03-dd39fff9637f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/7805ba52-9302-605c-75b6-33f1a245afa3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/7f224ef7-bc4a-f5f5-a601-a5d6ccda5942.png)


# SketchUpの観察
SketchUpで、どの様な表現が行われているのかを観察します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/a192c597-19ce-6e4e-0524-e233a3d607eb.png)
### 再現する要素
テクスチャ効果など、良い表現がありますが今回は以下の３点に絞って再現しました。

<dl>
  <dt>輪郭強調</dt>
  <dd>輪郭線が太くなる</dd>
  <dt>はみ出し</dt>
  <dd>線が角を通り越している</dd>
  <dt>ぶれ</dt>
  <dd>直線が歪んでいる</dd>
</dl>


# Houdiniでの実装
基本方針としてはその効果をビューポートで確認できる方法で実装していきます。
すなわち、スケッチスタイルのエッジをモデルと同じ空間に生成する方法です。

### 仕様
・モデルを入力すれば自動でスケッチスタイルのエッジを生成します。
・スケッチスタイルのエッジは大きく分けて二種類。通常と輪郭線。
・輪郭線以外のエッジは、エッジグループで指定します。
・線の太さは奥行きに関わらず一定で任意の太さを設定できます。

## 輪郭線の生成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/0671ec75-cc32-32c5-79ae-01ad6ddd5a20.png)
まずは、このような輪郭線を生成します。

### シーンの準備
対象のオブジェクトを生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/0149cbfa-09af-c2a0-14ab-39b3bc74189e.png)

輪郭線というものはカメラに依存するものなので、通常のカメラを生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/bef4889e-3137-1218-405b-bdc0e50ec83e.png)
構図や画角、解像度などは自由です。

### カメラ空間への変換
今回の処理の肝である、オブジェクトをワールド空間からカメラ空間への変換を行います。

3DCGの基礎を学んだ方なら、3Dワールド座標系のモデルは**透視変換**という処理を経てカメラから見た形状に変換される事はご存知だと思います。その**透視変換**は通常の場合、描画の直前に自動で行われる処理なので、一般的なアーティストはあまり意識する事はありませんが、今回は輪郭線などを抽出するためにモデリングの途中に意図的に行います。

#### NDC(Normal Device Cordinates)
カメラ座標の定義として、標準デバイス座標、NDC(Normal Device Cordinates)というものがあります。
その定義は、カメラに映る領域の横方向をX軸として0〜１の範囲、縦方向をY軸とした0〜１の範囲。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/2caf6e62-6c30-5270-eacf-7eedb93f07b4.png)

カメラの位置を０、カメラからの距離をZ軸のマイナス方向としています。すなわちZがプラスの場合はカメラより後ろの物なので見ることができません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/2a749014-156e-593b-59b8-62f36c04c73c.png)

#### toNDC
HoudiniのVEXには、ワールド座標をNDCに変換する関数**toNDC**が用意されています。

https://www.sidefx.com/ja/docs/houdini/vex/functions/toNDC.html

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/5bf61fad-7fd5-e603-8926-982fd3731814.png)

attribute wrangle(point)に以下のコードを書くだけで、ワールド座標をNDCに変換することが出来ます。
```c
@P = toNDC('/obj/cam1',@P);
```
変換前のcam1からの見え方。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/9f90a67d-bc77-9199-e5ac-7dc309c33b16.png)
NDC変換後の形状。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/1032dc10-0866-7485-22b3-54638fa5d6cc.png)

簡単ですね。

しかし、少し注意が必要です。
基本的にNDC変換後のオブジェクトを、Fron(Z+方向からの)平行投影カメラで見れば、変換時に参照したカメラからの見た目になるのですが、レンダリング解像度が縦横で異なる場合、縦も横も０〜１の範囲で表されるNDCでは、一般的な横長のレンダリング解像度の場合、NDCでは縦に伸びた絵になってしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/df8e7a5e-274c-9fd5-758c-ba0d1920fc5e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/86c6aa6e-a90f-5d35-2b63-4fb2ecb4f11c.png)

そこで、カメラに設定された、レンダリング解像度情報を参照し、画像の縦横比を求め、モデルをスケールする必要があります。
先ほどのAttribute Wrangle(point)を以下のように書き換えます。
```c
//カメラのパスを取得
string cameraPath = chs('cameraPath');
//カメラに設定された解像度を取得
int resX = chi(cameraPath+"/resx");
int resY = chi(cameraPath+"/resy");
//縦横の解像度を元にアスペクト比を求める
float aspect = (float)resY/resX;
@P = toNDC(cameraPath,@P);
//Y座標にアスペクト比を掛ける
@P.y *= aspect;
```
カメラパスを指定するパラメーターが追加しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/c51c4a8f-47bb-f5fb-6bca-9710cc6cb833.png)
これで、縦横比の正しいモデルを生成する事ができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/1a32e40e-8d74-fbc2-8518-71783ff46cad.png)

### 輪郭線の抽出
輪郭線の抽出を行なっていきます。
まずは、Group SOPを使ってカメラに背を向けるポリゴン（Zマイナス方向に法線が向いているポリゴン）のprimitiveグループを生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/12efead5-cf62-2820-ffcb-053c7c8f59b8.png)
その後、Blast SOPで先ほどのprimitiveグループに含まれるポリゴンを削除します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/1faa0ef6-9880-3a16-43f8-47bca587d65b.png)

この削除で出来る境界エッジが、輪郭線になります。
見やすいようにマテリアルを非表示にし、境界エッジを青く表示させました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/6b7ac4f1-38d0-27da-aedf-d5a652a5848c.png)

その後、Group SOPのUnshared EdgeオプションをONし、境界エッジからEdge Groupを生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/d4f98443-4322-d949-f0b8-f2abb5a1590d.png)

PolyPath SOPでパスに変換。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/746c99ee-94f3-90c7-ae1a-afa10abd6c6a.png)

Dissolve SOPを仕様し境界エッジを元に作られたEdge Groupに含まれるエッジ以外を消去します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/26a0ad69-b6f0-859a-816c-532c2ed961c2.png)

現状ではPrimitiveが細切れなので、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/16af3e64-3624-707a-db60-3e29d0931740.png)

PolyPath SOPを使用し、Primitiveを連結させます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/518347a6-ee22-1fb1-43c1-0eb69d8c55ea.png)

このままでも問題はないのですが、負荷軽減のために、Ray SOPを使用して、遮蔽により見えない輪郭線を削除することも出来ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/c20a23ec-1d58-d904-e315-b3582b1894ce.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/f7779b2d-4045-0063-4f64-c74954c66fb4.png)

ここまでで、輪郭線の抽出は完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/6a515581-e7aa-10ad-5766-331294088693.png)
斜め上から見ると、輪郭線とは思えない形状になっていますが、これでOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/9a0e14ab-81fc-e2b5-833e-58f7299235fa.png)


### 輪郭線を太くする。
シンプルに Poly Wire SOPでポリゴン化し太くします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/b9356bc1-d8c4-d0b8-0825-499ce8017453.png)
NDCの中では、すでに透視変換が行われた状態なので、この空間で太さが一定の線を描けば、カメラからの見た目では、奥行きに関係なく太さが一定になります。


### NDCのモデルをワールド空間に変換する。
#### fromNDC
先ほど使用したtoNDC関数の逆の変換を行う、fromNDCという関数があります。

https://www.sidefx.com/ja/docs/houdini/vex/functions/fromNDC.html

attribute wrangle(point)に以下のコードを書くと、NDCをワールド座標に変換することが出来ます。
fromNDCを使用する前に、アスペクト(縦横)比の逆数を@P.yに掛けることを忘れないでください。
```c
//カメラのパスを取得
string cameraPath = chs('cameraPath');
//カメラに設定された解像度を取得
int resX = chi(cameraPath+"/resx");
int resY = chi(cameraPath+"/resy");
//縦横の解像度を元にアスペクトを求める
float aspect = (float)resY/resX;
//Y座標にアスペクト比の逆数を掛ける
@P.y *= 1/aspect;
//NDCからワールド空間へ変換
@P = fromNDC(cameraPath,@P);
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/78530b74-d6a7-1924-3428-6c86f72b8833.png)

Color SOPで色を頂点カラーとして設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/7ed41e12-5491-39f5-4e7b-cf20ed7a02b5.png)

最後にMarge SOPを使用し、入力メッシュと合成すれば完成です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/f71c8230-edf7-a516-b8f0-21705f789f74.png)

NDCをワールド座標に変換する、Attribute Wrangleの前に、Transform SOPで、Zプラス方向にオフセットすると線が強調されます。調整してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/2e83041b-f622-484f-4ac9-0b6c7c60f7cd.png)

これで、輪郭線はとりあえずの完成です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/cad05808-6dd8-ca45-a81e-6eafb66ac294.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/0c8ac718-86ca-50d7-8cb6-5e61ef8bd1a4.png)

## 「はみ出し」と「ぶれ」を加える
スケッチスタイルの特徴である、「はみ出し」と「ぶれ」を加えていきます。

まずは、対象のオブジェクトを輪郭線に直線と曲線を含む物に変えます。
今回は、Test Geometry:ShaderBall SOPの、vMantra Classicを使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/f42c977b-0ad4-74a7-2a79-d788f78fe98e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/834cc8ef-7593-ee6c-021f-252d975c875d.png)
輪郭線が入った状態。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/a48a729e-20ab-09ac-34f6-e84fa51eda8b.png)



「はみ出し」と「ぶれ」は、線を太くする、Poly Wire SOPの直前で行います。
再利用するために、effectというサブネットの中に実装していきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/cc93997b-3cb7-ad82-9956-229d002cb62e.png)
NDCで輪郭線の抽出が終わった状態から始めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/ba4b3a03-8327-112e-a126-34b1a8673a71.png)

### 準備
「はみ出し」も「ぶれ」も２次元的な効果なので、そのための準備をしていきます。
サブネット、effectの中でまず初めに使用するノードは、
attribute wrangle(point)です。
```c
//座標のZを退避
f@__orgZ = @P.z;
//Zに０を設定し、XY平面の２次元に。
@P = set(@P.x,@P.y,0);
```
XY平面の2次元に
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/ef8abd90-f3b9-33b9-1066-27feafb4f420.png)

角を検出するために、Facet SOPの、Remove Inline PointsをONにし、Distanceを角が抽出できる丁度良い値に調整してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/018a4f68-f041-9a6a-984e-4b7f07584093.png)

Group SOPを使用し、抽出したポイント全てを含んだPoint Groupを生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/39be1104-16cc-1b83-484e-9d87529d6bba.png)
Group Transferを使用し、先ほどのPoint Groupを元の輪郭線に転写します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/894178f6-bd9b-7b29-87c5-5eb136937631.png)
次に、polyCut SOPを用い、検出した角のPoint Groupを使用して、カーブを分離します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/42f85983-0795-594b-fd87-99829d14d162.png)
Sort SOPを使用し、By Vertex Orderでポイントの順番を揃えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/4fea2f72-b570-ed9e-443f-58b175ea202a.png)
Orient Along Curve　SOPを使用し、接線のベクトルを求めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/5780accc-24ec-080d-1d3a-9e277825c0d3.png)
attribute wrangle(point)でZ座標を復元すれば、準備完了です。
orientalongcurve![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/72d8fadb-3c31-619c-db32-34f1425baa3d.png)

### 「はみ出し」の表現
基本的に、Primitive(カーブ)毎のループ処理で、
カーブの最初(Point番号 0)と、最後（Point 番号`npoints(0)-1`)をBlast SOPで抽出し、
Copy To Point SOPでLine SOPで生成された線分をコピーし、Merge SOPを使用し元のカーブと合成。
join SOPで1つのカーブに結合しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/0b4e4c70-6d82-ed36-3002-6e867bc30946.png)
あとは、これまで通りの処理を通すことで、「はみ出し」の表現が完成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/25f6ad60-c698-cb37-2087-fcdb87233937.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/479031ad-c154-3fad-d399-f26929db4029.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/6e8bfc03-fc10-e3ec-5f06-3b5987f5ae38.png)

### 「ぶれ」の表現
「ぶれ」の表現は、「はみ出し」の表現のループ処理の後半に3つノードを足して実装します。
まずは、Resample SOPでカーブを均等に分割します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/2466b134-a2ee-9a38-94e1-87acdb3513a8.png)
次に、平面的なノイズを与えるためのアトリビュートをattribute wrangle(point)で生成します。
Z軸に０を代入した、XZ平面の座標を生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/cbb3ed70-5f1d-ac0a-2f0c-c25964de20f8.png)

最後にノイズをAttribute Noise SOPで加えます。
Location Attributeには、先ほど生成したZ軸に0を代入した位置アトリビュートを。
残りのパラメーターは、AmplitudeとElementSizeを中心にお好みで設定してください。
Offsetを以下のように設定すると、カーブ毎にノイズの起点が変わるので自然になります。
```c
point(0,0,'P',0)*100
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/cd1222b6-8783-f880-26c0-eac261bc6613.png)
これまで通りの処理を通すことで、「ぶれ」の表現も完成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/72a0df59-a838-f98f-4cb5-cf5cec402af6.png)

## 「はみ出し」と「ぶれ」のある輪郭線の完成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/14e88a50-7e35-773d-1b09-33c47b389dc1.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/00e9986a-c87f-1160-d203-e01e7760a684.png)

## 輪郭線以外のエッジの生成
### 準備
輪郭線以外の、描画したいエッジのEdge Groupを今回は簡単に、Group From Attrib Boundary SOPでノーマルを参照し法線のハードエッジから生成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/c4bf23a5-bd65-d8b9-588c-fe9179002d81.png)
エッジの抽出は、dissolveで輪郭線を抽出した同じタイミングで、同じくdissolveで行います。
指定したEdge Groupから、輪郭線のEdge Groupを含まないもののみを残してエッジを消去します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/2416c583-2770-0988-3032-30bb9d94333b.png)
以降は、輪郭線と全く同じ処理を行うだけです。
エッジの太さや、ノイズのパラメーターを輪郭線の物と変えることで表情を豊かにすることが可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/9e4704b8-1bba-313a-d916-6db0bb9b6f32.png)
これで、目的の、「はみ出し」と「ぶれ」がある輪郭線と、それ以外のスケッチスタイルエッジの生成ができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/ceae5d83-e68d-018e-2246-5aae6e8975b7.png)


# HDA化
この仕組みを簡単に再利用できるようにHDAにします。
今回は私が実装したHDAのUIを解説することにします。皆さんも、自身の使いやすい機能とUIを設計してみてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918826/e71ca92e-d099-7101-d334-b34480034e6e.png)
輪郭線の他に二種類の通常エッジを生成できるようにしました。

# 作例
基本的にHoudiniのビューポートのキャプチャ画像を色調整したものです。
少し前に実装された、Labsのphysical　ambient　occlusion SOPで元のモデルにグラデーションをつけています。
![image](https://pbs.twimg.com/media/GB37BPVa0AEasrn?format=jpg&name=4096x4096)
![image](https://pbs.twimg.com/media/GB37BPZbQAA4qv3?format=jpg&name=4096x4096)

![image](https://pbs.twimg.com/media/GB3_hXsbwAAAzmP?format=jpg&name=4096x4096)
![image](https://pbs.twimg.com/media/GB3_hXsagAAE04F?format=jpg&name=4096x4096)

![image](https://pbs.twimg.com/media/GB4i_ofbQAATDJN?format=jpg&name=4096x4096)
![image](https://pbs.twimg.com/media/GB4jAiPa8AAeEFs?format=jpg&name=4096x4096)

![image](https://pbs.twimg.com/media/GB6jHJvaIAAPejq?format=jpg&name=4096x4096)
![image](https://pbs.twimg.com/media/GB6jHJyagAApHR9?format=jpg&name=4096x4096)




# 最後に
今回、ワールド座標空間とカメラ座標空間NDCを行き来する手法で、スケッチスタイルのエッジ描画を紹介しました。
座標系を行き来する事で、さまざまな効果（エフェクト）を実現する事が可能です。ちょっとしたアイデアでも大きな効果を得られると思います。
陰線処理をZ bufferに頼ってしまいましたが、陰線処理を高速に行う事ができれば、より表情豊かなスケッチスタイルのエッジを生成することができると思うので、チャレンジしてみたいと思います。また、今回は「線」だけの表現を実装しましたが、今後、「塗り」の表現も試していければと思います。

この記事が皆様の何かのお役に立てていただければ幸いです。それでは良いお年を。