概要
tranp紹介記事、第4弾です
前回に引き続き、Unreal Engineのマテリアル関数の作成を題材に解説していきます
前回の予告と相違してしまいますが、今回はマテリアル関数の実装について、もう少し深掘りしたいと思います。前回は説明のため簡潔さを優先していたので、今回はより実践的な例を紹介したいと思います
初読者に対して
tranpはPython製のトランスパイラーです。この記事では、tranpを用いてPythonのソースコードからUnreal Engineのマテリアル関数を実現する方法を解説しています
前回までの記事を前提にしているので、不明な点があればご一読お願いします
リンク
資料
以下、マテリアル関数のサンプルソースへのリンクです。GitHubにアップロード済みのサンプルを題材に解説していきます
初めに
題材としてマテリアル関数を扱う以上、数学的な演算が多かれ少なかれ記事に含まれます。しかし、数学的な話には触れず、tranpを利用した実装例の紹介という形で話を進めていきます。学術面の詳細を理解したい方は、専門書の参照をお勧めします
逆に言えば、この程度の数学力でもマテリアル関数って作れるんやな。と共感できる方には価値があると言えるかもしれませんw
以上、言い訳も終わったところで、本題に入りましょう
目次
Math
math_atan2_clock
後述するuv_to_vectorを使って円グラフを描く場合、atan2_fastを使うと3時方向が始点の時計回りになります。しかし、12時方向が始点の時計回りにしたい場合もあるでしょう。そのためのヘルパー関数です
マテリアル関数では円周をPI * 2ではなく、正規化した0 ~ 1で扱うことも多いため、0 ~ 1で返します
あやしいコメントが記載されていますが、知覚出来ないほどの差しかない場合が殆どなので、妥協しても問題ないだろうと言う理屈です
前回のおさらいとして、Annotatedの第2引数はプレビューの初期値です。プレビューの値は実際は使われず、x, yの引数は省略できないため、実行時は必ず実引数の値になります
def math_atan2_clock(y: Annotated[float, 0.0], x: Annotated[float, preview_alpha(1.0)]) -> float:
"""Arctangent2Fast。0時を始点に時計周りに1周の割合(0 ~ 1)を返す
Args:
y: Y座標
x: X座標
Returns:
1周の割合 (0 ~ 1)
Note:
```
XXX (0, 0)の時に180度になる不具合が存在
XXX しかし大抵は問題にならず、回避するための計算コスト増加の方が問題として大きいため、許容することにする
```
"""
return ME.atan2_fast(-x, y) / (2.0 * ME.pi()) + 0.5
トピック
| 項目 | 説明 | 補足 |
|---|---|---|
| ME.atan2_fast | atan2_fast関数 | atan2の近似値を算出 |
| ME.pi | 円周率(PI)の値 | Python上は関数だが、実際は定数 |
プレビュー
Color
color_to_srgb
リニアカラーをsRGBに変換する関数です。共通関数は1次元しか扱えず手間なので、それを解消するヘルパー関数です。もちろん逆変換を行うcolor_to_rgbもあります
補足として、リニアカラー/sRGBを扱う専用の型はなく、どちらもただの3次元ベクトルです。この関数は色空間の変換を行うだけの関数なので、sRGBをsRGB変換したら色が壊れてしまいます。入力値の色空間がどちらなのか、自分で管理しなければならない点に注意が必要です
def color_to_srgb(color: Annotated[float3, float3(1.0, 0.5, 0.0)]) -> float3:
"""sRGBに変換
Args:
color: 色
Returns:
sRGB
"""
return float3(ME.rgb_to_srgb(color.x), ME.rgb_to_srgb(color.y), ME.rgb_to_srgb(color.z))
トピック
| 項目 | 説明 | 補足 |
|---|---|---|
| float3 | 3次元ベクトル | - |
| ME.rgb_to_srgb | RGB → sRGBの変換関数 | 1次元のみ |
プレビュー
Matrix
matrix_rotation2d
(0, 0)を中心とした2次元ベクトルを回転する関数です
回転角度はmath_atan2_clockで述べた様に、円周を0 ~ 1で扱います。これはsin/cos関数のデフォルトの基数に合わせるためでした
ちなみに、自分ルールで基数毎に命名規則を分けています。u_xxxは0 ~ 1、radianは0 ~ PI * 2、degreeは0 ~ 360°といった感じです
def matrix_rotation2d(vector: Annotated[float2, preview_vector()], u_theta: Annotated[float, 0.0]) -> float2:
"""ベクトルを回転
Args:
vector: ベクトル
u_theta: 回転角度 (0 ~ 1)
Returns:
ベクトル
"""
xv = vector * ME.cos(u_theta)
yv = vector * ME.sin(u_theta)
return float2(xv.x - yv.y, yv.x + xv.y)
トピック
| 項目 | 説明 | 補足 |
|---|---|---|
| ME.cos | cos関数 | デフォルトの基数は1。基数はPI * 2や360にすることも可能 |
| ME.sin | sin関数 | 〃 |
プレビュー
UV
uv_to_vector
UV座標の(0.5, 0.5)を原点(0, 0)として、(-1, -1) ~ (1, 1)の範囲の2次元ベクトルに変換する関数です
この関数自体に特筆すべきところはないように思えますが、SDF(Signed Distance Field)を実装する上で必ずといってよいほど利用する、非常に有用な関数です。実装しておいて損はないでしょう
def uv_to_vector(uv: Annotated[float2, ME.tex_coord(0)]) -> float2:
"""UV座標からベクトルに変換
Args:
uv: UV座標
Returns:
ベクトル (-1 ~ 1)
"""
return uv * 2.0 - 1.0
トピック
| 項目 | 説明 | 補足 |
|---|---|---|
| float2 | 2次元ベクトルの型 | |
| ME.tex_coord | UV座標を取得する関数 | 引数の「0」はUV番号 |
プレビュー
Mask
mask_dissolve
前々回に冒頭のサンプル画像として提示した、ディゾルブ(消失)エフェクトを作成するためのマスク生成関数です
マテリアルエディター上で見ると1画面で収まらない程に煩雑ですが、Pythonで書くと実はこんなに簡単です。やっていることは閾値と比較して0 or 1のビットマスクと、0 ~ 1のアルファマスクを作っているだけです
この関数では簡略化のためノイズテクスチャーが関数内で固定になっていますが、これを引数にすることで様々なパターンのエフェクトに対応することも可能です
def mask_dissolve(alpha: Annotated[float, preview_alpha(0.5)], smooth: Annotated[float, 0.25], scroll: Annotated[float2, float2(0.1, 0.1)]) -> tuple[float, float, float]:
"""消失マスクを生成
Args:
alpha: 進捗 (0 ~ 1)
smooth: 浸食の均し範囲 (0 ~ 1, default = 0.25)
scroll: マスク画像のスクロール速度 (default = 0.1:0.1)
Returns:
(マスク, 浸食マスク, 浸食α値)
"""
intensity = ME.sample(texture(MFImages.Noise.value), ME.panner(ME.tex_coord(0), scroll)).x
expand = alpha + ME.min(alpha, smooth)
zero = float(alpha <= 0.0)
mask = ME.max(1.0 - float(intensity <= alpha), zero)
min_mask = ME.max(1.0 - float(intensity <= expand), zero)
min_alpha = ME.max(ME.smoothstep(alpha, expand, intensity), zero)
return mask, min_mask, min_alpha
糖衣構文
float(alpha <= 0.0)という見慣れない構文がありますね。これは見た目のままboolをfloatに変換しているだけで、結果は当然0 or 1です。勘の良い方は気付いたかもしれません。これはstep関数です
step関数は入力値と閾値を比較して0 or 1を返す共通関数です。単純な関数ですが、実はこれが非常に扱いづらく、関数のシグネチャーと判定したい式がどうしても感覚と一致しません。これを解消するため、糖衣構文を実装してトランスパイル時に置き換えています。こういった構文の柔軟性がトランスパイルを行う最大のメリットですね!
トピック
| 項目 | 説明 | 補足 |
|---|---|---|
| ME.min | min関数 | - |
| ME.max | max関数 | - |
| texture | テクスチャー | 第1引数: テクスチャー名 |
| ME.sample | テクスチャーサンプリングの関数 | 第1引数: テクスチャー |
| 第2引数: UV座標 | ||
| ME.panner | UV座標をスクロールする関数 | 第1引数: UV座標 |
| 第2引数: スクロール速度 | ||
| ME.smoothstep | 入力値を閾値の範囲で緩やかに補完する関数 | 第1引数: min |
| 第2引数: max | ||
| 第3引数: alpha |
プレビュー
Pixel
pixel_antialias
アンチエイリアスを算出する関数です
しかし、なんにでも利用できる訳ではなく、特定のパターンが必要です。コメントに記載の通り、引数のintensityと周辺ピクセルに関数的な連続性がある場合にのみ有効に機能します。これは円を例に考えるとイメージし易いです
アンチエイリアスは非常に大雑把に言うと、図形の外周付近にだけ付くα値です。円の中心から外周に向かってintensityが0 ~ 1へ線形的に増加していくことを考えたとき、0から1へ変わる境界はつまり円の外周になります。この境界付近で急激に減少したα値を返すのがこの関数です
もちろん、円だけでなく多角形やドーナツ形状でも問題ありません。形状に寄らず、あくまでもintensityと周辺ピクセルの連続性だけが適用条件です
偉そうに言っていますが、結果的にそうなる程度の理解度です。ま、まぁこれでええやろ…
def pixel_antialias(intensity: Annotated[float, preview_distance()]) -> float:
"""強度からα値を算出
Args:
intensity: 強度 (0 ~ 1)
Returns:
α値
Note:
```
### 利用条件
* 強度はUV座標を基に算出
* 周辺ピクセルとの強度の勾配は関数的な連続性が必要
### 非利用条件
* UV座標を基にしていても、画像の様な非連続的な強度に対しては効果が無い
### DDX/DDYの必要性
* 強度が関数的なら定数で割った方が効率が良く結果もほぼ等しい様に思えるが、結果は伴わない
* 定数: 解像度によって強度の勾配が変化してしまう上、そもそも勾配を考慮できないため、極端な勾配になると中間値が無くなりアンチエイリアスが利かない
* DDX/DDY: 解像度が変化したり、極端な勾配であってもある程度均一な結果になる
```
"""
dx = ME.abs(ME.ddx(intensity))
dy = ME.abs(ME.ddy(intensity))
# 下限値: 0除算の対処
# 上限値: 数値に必然性はなく、0.5 ~ 1.0程度が適量。大きいほどアンチエイリアスが利く半面、極端に小さい・細い図形が潰れる。DPIスケールと相関性がある?
width = ME.clamp(dx + dy, 0.001, 0.7)
return ME.saturate((1.0 - intensity) / width)
トピック
| 項目 | 説明 | 補足 |
|---|---|---|
| ME.abs | abs関数 | - |
| ME.ddx | DDX関数。周辺ピクセルとの勾配を算出(X軸) | ピクセルシェーダーの関数 |
| ME.ddy | DDY関数。周辺ピクセルとの勾配を算出(Y軸) | 〃 |
| ME.clamp | 入力値を範囲内でクランプする関数 | - |
| ME.saturate | 入力値を0 ~ 1の範囲でクランプする関数 | clamp(n, 0, 1)と結果は等価だが、シェーダー的に高速 |
プレビュー
最後に
最後までお読みいただき、ありがとうございます!
今回はひとまずここで一区切りとします。GitHubの方に今回紹介していない関数も上がっているので、興味がありましたらご確認ください!
ご意見・感想は本サイト、またはGitHubのIssue等で気軽にコメント頂けるとありがたいです
次回予告
次回は引き続きマテリアル関数の解説を行う予定です
前回の予告と同じく、実際にマテリアル関数のアセットへ変換を行う辺りの解説予定しています。が、準備が整わない場合は、別の題材も検討しています…!
お楽しみに!





