Contents

VIPF (可変マット額縁) の解説

飾り切れない量になってきました。あと最近謎シェーダーをあんまり書いてなかったので久しぶりにシェーダーネタです。

VIPF とは

VIPF (Variable Inset Picture Frame / 可変マット額縁) は、様々なサイズの画像をいい感じの見た目で飾るための額縁アセットです。 画像のサイズに合わせて形状が変わる額縁といえば momoma 氏の自動サイズ調整額縁がありますが、あちらは額縁全体のサイズが変わるのに対してVIPF は額縁全体のサイズはそのままに 内側の額装マットの部分のサイズが変わることでメッシュの粗が見えなくなるようになっています。

なぜこんなものを作ったかというと、自分の Skeb 展示ワールドで使いたかったからです。最初は 1:1, 4:3, $\sqrt{2}:1$, 16:9 の固定サイズの額縁モデルを作って内側のポリゴンのサイズだけ変えるようにしてたんですが、 やっぱり見た目が微妙というか額縁本体の裏側が見えないほうが見映えがいいのではないかという指摘があり、じゃあ可変額縁ごと作ろうとなったわけです。

使い方

先に VIPF の使い方を説明します。

  1. 専用シェーダー KusakaFactory/VIPFPictureMat を設定したマテリアルを作成します。
  2. 飾りたい画像のテクスチャアセットと、元画像の解像度を入力します。ただし、アスペクト比が一致していれば必ずしも同じ解像度である必要はありません。
  3. (オプション)額装マットのテクスチャを指定します。

/posts/vipf-breakdown/material-inspector.png
上記内容を設定した例

これで完成です。実際の見た目はこんな感じ。

/posts/vipf-breakdown/example1.jpg
完成図。マット部分はちゃんと盛り上がってるよ

VIPF の真価はここから。解像度にどのような値を設定しても、はみ出さないように中身が変形します。マット部分もテクスチャが歪まないように自動で変形します。すごいでしょ?

/posts/vipf-breakdown/example2.jpg
例えば √2:1 にしてもマットのテクスチャは歪まない

技術的解説

ここからは技術的な話です。端的に言えば 「Vertex Color と Vertex Shader」 です。これだけでわかる人はもう帰ってもいいぜ。

動機

これを作る上で外せなかったポイントとしては、

  • Unity のエディタ拡張スクリプトを書かない
  • ライティングを極力破壊しない

の 2 つがありました。前者は以前 SK2AAC を作った際の LT 発表でも話したように避けられるなら避けたいという感情がありました。 後者については、まあ色々ありますが 3DCG おたくとしては「額縁そのものがイロモノとして存在して環境で目立ってしまうのはよくない」というような思いがあり、あくまで普通の額縁モデルとして扱えるようにしたかったのが大きいです。

これらを総合して、Unity のシェーダー芸ではあまり使われなさそうな Surface Shader を書くという方向性で実装することにしました。 Surface Shader は、よく使われる Unlit Shader とは違って(この名前もおかしい気はするが)、マテリアルのパラメーターだけを計算したら残りのライティングの計算はパイプライン側でやってくれるというようなものです。 float4 frag(Input) で最終出力を決定するかわりに void surf(Input, inout Output) というようなシグネチャの関数で Albedo や Metallic といったパラメーターを計算し、パイプライン側に渡す構造です。 これならライティングは基本的に Unity 標準のそれに従うので、後者についてはほぼ解決できるといえるでしょう。

エディタ拡張を書かないためには、エディタ拡張以外でサイズ可変にするギミックを作る必要があります。頂点を動かすので Vertex Shader 一択です。 Surface Shader には Vertex Shader の関数も定義できるので、通常の MVP 行列を適用する以外のこともできます。

額縁モデル (Vertex Color)

最初に額縁のモデルを作るのすが、この時点でちょっとした細工をする必要があります。ずばり、Vertex Color / 頂点カラーです。

Vertex Color は頂点ごとに与えられるデータの一つで、座標や法線、 UV 座標などのように比較的広く、そして古くから存在するものです。 固定パイプラインが主流だった時代にはこのデータは文字通り色情報として、モデルを描画する際のテクスチャのように使われていたりしたようです。

しかし最近ではレンダリングパイプラインを自由に組み立てられるようになり、プログラマブルシェーダーも普及し、そしてテクスチャは豊富に使えるようになりました。 それに伴って Vertex Color の色としての役目は薄れていきましたが、依然として頂点単位でシェーダーから取得可能な情報の一つではあります。 これを利用して、 Vertex Color に色以外の情報を詰めてシェーダーで独自の解釈をしてレンダリングに活用する事例が増えてきました。

Vertex Color の情報を活用したゲームとしては、「GUILTY GEAR Xrd -SIGN-」などが知られています。このゲームでは、輪郭線や陰影の制御に使っているようです(cf. 西川善司の「試験に出るゲームグラフィックス」(1)「GUILTY GEAR Xrd -SIGN-」で実現された「アニメにしか見えないリアルタイム3Dグラフィックス」の秘密,前編)。

VIPF では、以下のようなデータを頂点カラーに保存することにしました。なお、これはマットと画像の部分の頂点のみに設定され、フレーム側には設定していません。VIPF ではフレームは変形しないからです。

  • 🟥 R Channel
    • 画像サイズに対する X 方向の頂点移動倍率
    • $ [0.0, 1.0] $ の範囲を $ [-1.0, 1.0] $ にマッピング
  • 🟩 G Channel
    • 画像サイズに対する Y 方向の頂点移動倍率
    • $ [0.0, 1.0] $ の範囲を $ [-1.0, 1.0] $ にマッピング
  • 🟦 B Channel
    • 画像テクスチャとマットテクスチャのブレンド率
    • $ 0.0 $ ならば Roughness = 0.0, Smoothness = 0.0 の Standard マテリアルとしてマットのテクスチャを描画する
    • $ 1.0 $ ならば Albedo = 0.0 とし、 Emission で画像のテクスチャを描画する(PC モニターのようになる)
  • ⬜ A Channel
    • 未使用

頂点移動倍率というのは、**「画像のアスペクト比に合わせるために本来の頂点位置から動かす必要がある量に対して実際に動かす倍率」**です。これだけだと何を言っているかわからないですね。 例えば計算によって X 軸方向に 0.3 動かさないといけないことがわかったとしましょう。 このとき、 R に 1.0 が設定されていた場合は 1.0 にマッピングされるので、そのまま X 軸正方向に頂点が 0.3 だけ移動します。 逆に 0.0 が設定されていた場合は -1.0 にマッピングされます。このときは X 軸負の方向に頂点が 0.3 だけ移動します。 0.5 のときは 0.0 にマッピングされるため、画像のサイズとは関係なくその頂点は移動しません。

これらを総合して、次のように Vertex Color を設定します。

  1. あらかじめマット部分のポリゴンと画像部分のポリゴンは分離しておく。
  2. マット部分の頂点を次のように設定する。
    1. フレームと接する部分には $ (0.5, 0.5, 0.0) $ を設定する。画像の内容とは一切関係なくマットが表示される。
    2. 画像部分と接する部分には場所に応じて次のように設定する。画像中心を原点としてそれぞれの象限の方向に、画像がちょうど収まるように頂点が移動する。
      • 左上: $ (0.0, 1.0, 0.0) $
      • 右上: $ (1.0, 1.0, 0.0) $
      • 左下: $ (0.0, 0.0, 0.0) $
      • 右下: $ (1.0, 0.0, 0.0) $
  3. 画像部分の頂点を次のように設定する。マット部分の内接する頂点と同じように移動するが、マットではなく画像を表示する。
    • 左上: $ (-1.0, 1.0, 1.0) $
    • 右上: $ (1.0, 1.0, 1.0) $
    • 左下: $ (-1.0, -1.0, 1.0) $
    • 右下: $ (1.0, -1.0, 1.0) $

これを設定して Blender で表示したのが以下の画像です。

/posts/vipf-breakdown/vertex-color-visualization.jpg
Blender で 上記の Vertex Color を設定した状態。きれいですね

ところで、色といえば気になることがありますね?そう、Color Space / 色空間です。Blender は Vertex Color をどのような空間の値として扱うのでしょうか?

正解は、 「設定されている値は Linear Space の値であるとして解釈する」 です。多分。具体的には次のような挙動をします。

  • 少なくとも FBX ファイルにエクスポートする分には設定した通りの値がエクスポートされます。
    • Blender の色設定で $ (0.5, 0.5, 0.0) $ を選択したら、そのまま FBX には $ (0.5, 0.5, 0.0) $ が書き込まれます。
    • HLSL の COLOR セマンティクスにも $ (0.5, 0.5, 0.0) $ という値が設定されます。
    • sRGB 空間のつもりで(つまり、色だと思って)設定した値であれば、色としてシェーディングに使う場合は sRGB to Linear 変換を適用する必要があります。
  • Blender で Color Attribute を使用した場合、Linear to sRGB 変換が適用された値が得られます。
    • つまり、設定した値より全体的に明るい色が得られます。
    • 0.25 ぐらいを設定してもマテリアルノードから参照すると 0.5 ぐらいの値として現れます。

今回は色として使うわけではないので、 1 番目の「設定した通りの値が COLOR セマンティクスに渡ってくる」という特性が重要になってきます。勝手に変換が挟まってなくてよかったぜ。

サイズ可変化 (Vertex Shader)

Chroma の HLSL サポート

急にシェーダーどころか Unity すら関係ない話を突っ込んでしまって申し訳ないんですが、この記事を書いている 2022-09-19 現在、Hugo が利用している Syntax Highlighter である Chroma は HLSL をサポートしていません。 そのせいでこの記事に登場する HLSL コードは pretty-formatted なコードブロックにはなっていません。ごめんね。

何もしてないわけではなく、Chroma に HLSL 対応してくれって PR は投げてます。 Hugo でいつ使えるようになるかはわからないけどね。

2022-12-04 追記: Hugo v0.107.0 で上記 PR が取り込まれた chroma 2.4.0 と一緒にビルドされるようになり、 HLSL をいい感じに表示できるようになりました。やったぜ。

VIPF の Vertex Shader は以下の通りです。

void vertex_main(inout appdata_full vi, out Input fi) {
    float2 original_inset = _Insets.xy;
    float2 extra_inset = _Insets.zw;
    float2 max_inset = original_inset + extra_inset * 2.0;
    float max_inset_ratio = max_inset.x / max_inset.y;

    // Inset vertex shifting.
    float picture_ratio = _PictureWidth / _PictureHeight;
    float2 target_inset = picture_ratio >= max_inset_ratio
        ? float2(max_inset.x, max_inset.x / picture_ratio)
        : float2(max_inset.y * picture_ratio, max_inset.y);
    float2 inset_shift = (target_inset - original_inset) / 2.0;

    // Imported model has inverted X and forward Y
    float2 import_scale_fix = _ScaleFix.xy;
    float2 inset_shift_scale = vi.color.xy * 2.0 - 1.0;
    vi.vertex.xy += inset_shift_scale * inset_shift * import_scale_fix;

    float2 uv_shift_scale = _ScaleFix.zw;
    UNITY_INITIALIZE_OUTPUT(Input, fi);
    fi.picture_mix = vi.color.z;
    fi.shifted_mat_uv = vi.texcoord.xy;
    fi.shifted_mat_uv += uv_shift_scale * inset_shift_scale * inset_shift * import_scale_fix;
}

original_inset が元のモデルにおける画像部分のサイズです。また、 extra_inset は片側に XY 軸それぞれで伸ばせる量が入ります。今考えたら後者はいりませんでしたね……。 額縁側の最大サイズのアスペクト比 max_inset_ratio と画像のアスペクト比 picture_ratio を比較し、少なくとも短辺か長辺のいずれかは最大サイズに張り付くように実際のサイズ target_inset を設定します。 そして頂点の移動量 inset_shift が求まり、これに Vertex Color に設定した頂点移動倍率 inset_shift_scale とかをかけてあげれば完成です。

import_scale_fix はモデルインポート・エクスポート時の座標軸変換を吸収するための値です。Blender から普通に出力して Unity に普通にインポートすると X 軸だけが反転します。これを補正するために $ (-1.0, 1.0) $ を設定しています。

最後の方にさらっと記述してありますが、実際の見た目を調整する上で重要なのが UV 座標の調整です。 UV 座標を移動しないまま頂点座標だけ移動すると、当たり前ですがテクスチャが歪みます。この歪みを解消するために、頂点が移動した分だけ UV も移動したかのように見せる必要があります。この UV の移動量のスケーリング値が uv_shift_scale です。

Surface Shader 本体は大して面白くないので省きます。

おわりに

Pixel Shader によるシェーダー芸は今まで何度か書いたことがありましたが、 Vertex Shader によるものは初めてだったので新鮮な感覚でした。 とはいえ Vertex Shader のほうが気軽にレンダリング結果をガッツリ変えたりできるので、 Pixel Shader なんもわからんという人は Vertex Shader からやってみてもいいのではないでしょうか。 あ、でもその場合 3D の座標変換と向き合う必要が出てくるのか……。やっぱり難しいね。

VIPF はそのうち配布予定です。ではまた!