【Unity】スクロールビューでもシェーダー芸がしたい!
2019/06/19 に開催された KLab TECH Meetup #4 で「FancyScrollView x Shader」というタイトルで登壇してきました。
内容は、自作のスクロールビューライブラリ「FancyScrollVeiw」を使って、シェーダー表現を取り入れたスクロールUIを作るというものです。
発表資料はこちらです。 docs.google.com
この記事は、発表内容をブログ向けにまとめたものになります。
FancyScrollView?
そもそも FancyScrollView とは何かというと、これは個人で開発している Unity 用スクロールビューコンポーネントで、下記のような特徴があります。
- 自由にスクロールアニメーションを実装できる
- データ件数を気にせず使える
- 表示に必要なセル数のみ生成され、セルはリサイクルされる
- スナップに対応&Easing指定も可
- 様々な手触りのスクロールUIが作れる
- 無限スクロール
- セルとスクロールビュー間でメッセージのやりとり
- 特定のセルにスクロールやジャンプ
- 細かいスクロールの挙動設定
FancyScrollView は GitHub か Asset Store からダウンロードできます。
https://www.assetstore.unity3d.com/#!/content/96530
仕組み
そんな FancyScrollView がどういう仕組みで動いているのかを説明します。
まずはセル側のスクリプトから簡単に見ていきます。
セルは、FancyScrollViewCell というクラスを継承して作ります。このときに、必ず実装する抽象メソッドが二つあります。
public class ItemData { public string Message { get; } public ItemData(string message) => Message = message; } public class Cell : FancyScrollViewCell<ItemData> { [SerializeField] Text message = default; public override void UpdateContent(ItemData itemData) { message.text = itemData.Message; } public override void UpdatePosition(float position) { // position は 0.0 ~ 1.0 の値 // position に基づいてスクロールの外観を自由に制御できます } }
一つ目は表示内容を更新するための UpdateContent
メソッドです。自分で定義したデータ型(この例ではItemData
)をジェネリクスで指定することで、外からそのタイプのリストを渡せるようになっています。セル側では渡されたデータをもとに、セルの表示内容を更新する実装をしておきます。
もう一つは、セルの位置を更新するための UpdatePosition
メソッドです。引数として 0.0 ~ 1.0
の値が渡されますが、これはセルが画面に出現して画面から消えるまでの割合を表しています。
セル側では、この0.0 ~ 1.0
の値を使ってスクロール中の動きを自由に定義することができます。シェーダー芸で使うような数式を駆使して transform
を更新しても良いし、サンプルでは Animator
を使ってスクロールの動きを実装しています。
とにかく、セル側では 0.0 ~ 1.0
の値をその時のセルの見た目に変換するような実装をしておきます。
次に、スクロールビューのインスペクタでセル同士の間隔を指定します。この例では間隔は 0.15
となっています。
間隔を指定すると、スクロールビューがこの間隔ずつズラした位置をそれぞれのセルに渡して、先ほどのセルの UpdatePosition
メソッドの実装によって、このようにセルが並びます。
画面を埋めるためにセルがいくつ必要か とか それぞれのセルがどの位置にいるか というのは FancyScrollView が計算して、スクロール位置によってよしなにセルをインスタンス化したりプーリングしたりしてくれます。
こういう仕組みで、自由度高めにスクロールの UI を作ることができるようになっています。
ライブラリとしては、この仕組みを提供しているだけで、演出とかは、使う人が自由に実装してください、というスタンスになってます。
そのため、そもそもセルが uGUI の要素である必要すらなくて、実装によっては 3D モデルが複雑にスクロールするような UI など、工夫次第で自由に作ることができるようになっています。
シェーダー表現を取り入れたスクロール
スクロールビューでもシェーダー芸がしたい!ということで、シェーダー表現を取り入れたスクロールUIのデモを二つ作りました。
実際に動くものはこちらから遊べます。
Unity WebGL Player | FancyScrollView
メタボールの解説
まずはメタボールのスクロールビューから解説します。構造は、大きく分けると セルのレイヤー と 背景のレイヤー の二つに分かれています。
セルは、Animator ベースで動きを制御しています。
そのセルの位置をリアルタイムにシェーダーに共有して、フラグメントシェーダー側で背景の Image にメタボールを描画するようになっています。
シェーダーにセルの状態を渡す
メタボールを描画するには、まず、シェーダー側にセルの位置や表示しているデータの Index など、セルの状態を渡す必要があります。
これには、FancyScrollView の Context という仕組みが便利なので使います。
Context は、一言で言うとセルとスクロールビュー間の橋渡し的な役割をするオブジェクトで、Context 経由で メッセージのやり取り や 何らかの状態の保持 が自由にできるようになっています。
今回は、この仕組みを使って、それぞれセルの情報を一箇所に集約して、シェーダーに共有するような実装をしています。
Background.cs シェーダーにセルの状態を渡す
background.material.SetVectorArray(ShaderID.CellState, scrollView.GetCellState());
Metaball.hlsl セルの状態を受け取る
#define CELL_COUNT 5 // CeilToInt(1f / cellInterval) float4 _CellState[CELL_COUNT]; // xy = cell position, z = data index, w = scale
渡すデータは四次元ベクトル配列になっていて、xy 要素にセルの位置、z 要素にセルのデータ Index 、w 要素に演出のためのデータを埋め込んでいます。
仕組み上、セルの最大インスタンス数は計算で求められるので、あらかじめその値を配列のサイズとしてシェーダー側に埋め込んでいます。
座標系を合わせる
次に、座標系を合わせる必要があります。
セルは uGUI の座標 で動いているのに対して、シェーダー側は uv 座標 に基づいています。
シェーダー内で uGUI 座標がそのまま使えるようになると、シェーダー側で位置とか大きさを合わせるのが楽になるので、シェーダー側でも uGUI の座標系で計算できるようにします。
Common.cginc スクリプトから uGUI 解像度を渡す
float2 _Resolution; // uGUI 解像度を受け取る float2 ui_coord(float2 uv) { uv -= 0.5; uv *= _Resolution; return uv; }
Metaball.shader 頂点シェーダ内で uGUI 座標に変換してフラグメントシェーダーに渡す
#include "../Common/Common.cginc" struct v2f { ... float2 uiCoord : TEXCOORD0; ... }; v2f vert(appdata_t v) { v2f OUT; ... // uGUI 座標に変換してフラグメントシェーダーに渡す OUT.uiCoord = ui_coord(TRANSFORM_TEX(v.texcoord, _MainTex)); ... return OUT; }
これで、試しにフラグメントシェーダー内でセルの位置をもとに縁を描画してみると、確かにセルの位置に合わせて円が描画できました。
// CeilToInt(1f / cellInterval) #define CELL_COUNT 5 // xy = cell position, z = data index, w = scale float4 _CellState[CELL_COUNT]; fixed4 frag(v2f i) : SV_Target { float st = i.uiCoord; float d = 0; [unroll] for (int i = 0; i < CELL_COUNT; i++) { d += step(length(st - _CellState[i].xy), 100); } return fixed4(d, d, d, 1); }
これでフラグメントシェーダー内で uGUI と同じ座標が扱えるようになったので、あとはセルの位置に合わせてメタボールを描画していきます。
セルの位置をもとにメタボールを描画
メタボールは検索すると Wikipedia に代表的な数式が載っています。
これを二次元バージョンに変形して使うことにしました。
このままだと絵としてさみしかったので、少しディスタンスフィールドを加工して絵を調整しました。
float f(float2 v) { return 1. / (v.x * v.x + v.y * v.y + .0001); } float4 metaball(float2 st) { float scale = 4600; float d = 0; [unroll] for (int i = 0; i < DATA_COUNT; i++) { d += f(st - _CellState[i].xy) * _CellState[i].w; } d *= scale; d = abs(d - 0.5); float3 color = 1; color = lerp(color, float3(0.16, 0.07, 0.31), smoothstep(d - 0.04, d - 0.04 + 0.002, 0)); color = lerp(color, float3(0.16, 0.80, 0.80), smoothstep(d - 0.02, d - 0.02 + 0.002, 0)); return float4(color, 1); }
タップ判定
次はタップ判定についてです。せっかくなのでタップ判定も作ってみました。
ボタンとか uGUI の要素は Unity があたり判定してくれるんですが、 メタボールの背景のモチモチ部分をタップして、何か反応するということができるようになります。
背景の GameObject にくっつけているスクリプトに、 IPointerClickHandler
と言うインターフェースを実装することで、背景がタップされたときに OnPointerClick
メソッドが呼び出されるようになります。
今は タップ位置 を uGUI座標 に変換する処理が書いてあります。
public class Background : MonoBehaviour, IPointerClickHandler { ... void IPointerClickHandler.OnPointerClick(PointerEventData eventData) { if (eventData.dragging) { return; } RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, eventData.position, eventData.pressEventCamera, out var clickPosition ); // clickPosition を使って当たり判定を実装 } }
タップ判定は、基本的に C# の世界でしか処理できないので、シェーダーの実装と同じ計算式を C# 側でも実装します。
Background.cs シェーダーと同じ計算式を C# 側でも実装
bool MetaballContains(Vector2 p, Vector4[] cellState) { float f(Vector2 v) => 1f / (v.x * v.x + v.y * v.y + 0.0001f); float scale = 4600f; float d = cellState.Sum(x => f(p - new Vector2(x.x, x.y)) * x.w); return d * scale > 0.46f; }
背景がタップされたら、このメソッドを使って、まずその位置がメタボール内に含まれているかどうかを調べます。
この時点でメタボールの外側にいたら、ヒットしてないことが確実なので、処理を中断します。
もし、メタボールの中にいたら、今度はどのセルが一番近いかを調べて、そのセルをタップしたことにします。
この図でいうと、タップ位置が、緑色の Cell 6 に一番近いので、 Cell 6 をタップしたと判定します。
Background.cs タップ判定部分のコードです
void IPointerClickHandler.OnPointerClick(PointerEventData eventData) { if (eventData.dragging) return; RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, eventData.position, eventData.pressEventCamera, out var clickPosition ); // タップ位置がメタボール内に含まれていなければ処理を中断 var cellState = scrollView.GetCellState(); if (!MetaballContains(clickPosition, cellState)) return; // 一番近いセルを調べて、そのセルをタップしたことにする var dataIndex = cellState .Take(scrollView.CellInstanceCount) .Select(s => ( index: Mathf.RoundToInt(s.z), distance: (new Vector2(s.x, s.y) - clickPosition).sqrMagnitude )) .OrderBy(x => x.distance) .FirstOrDefault() .index; scrollView.SelectCell(dataIndex); }
ボロノイの解説
次はボロノイスクロールビューの説明をします。
シェーダーにセルの状態を渡したり、座標系を合わせたりする部分はメタボールと全く同じなので、セルの位置をもとにボロノイを描画する部分から説明します。
セルの位置をもとにボロノイを描画
ボロノイは、単純に 各ピクセルから最も近いセルの Index によって色分けすることで、このようにボロノイ図を作ることができます。
形を整えるために、セル以外にも四隅に点を配置してこのような絵になってます。
今回はさらに、セルの境界線も描画したかったです。境界線がかけると、このような絵以外にも、いろいろ表現の幅が広がるためです。
単純にセルの位置を元にしたディスタンスフィールドはこのようになっていて、そのままではまっすぐな境界線を引くことができません。
欲しいのは、このように境界線からのディスタンスフィールドです。
境界線を描画
どのように境界を描画するかを説明します。説明のためにセルの位置を黄色い点で可視化しています。
これで、他のセルに対する境界 との距離がわかるようになったので、全てのセルに対して同じ処理をして、最短距離を採用します。
コードにするとこのようになります。
float4 voronoi(float2 st) { float2 cellPos = 1e+5; float cellIndex = 0, dist = 1e+9; [unroll] for (int i = 0; i < DATA_COUNT; i++) { float2 p = _CellState[i].xy; float2 q = st - p; float d = q.x * q.x + q.y * q.y; if (d < dist) { dist = d; cellPos = p; cellIndex = i; } } dist = 1e+9; [unroll] for (int i = 0; i < DATA_COUNT; i++) { if (cellIndex == i) continue; float2 p = _CellState[i].xy; float d = dot(st - (cellPos + p) * 0.5, normalize(cellPos - p)); dist = min(dist, d); } dist /= 200; return float4(dist, dist, dist, 1); }
これで、欲しかったディスタンスフィールドが計算できました。
あとは、ディスタンスフィールドを加工して色分けして、さらに、セルの選択状態もシェーダー側に共有することで、選択中のセルに集中線を入れて遊んだりしていました。
集中線も、極座標とノイズを使ってシェーダーで書いています。
シェーダーコードの全体像です。
#define CELL_COUNT 7 // CeilToInt(1f / cellInterval) #define DATA_COUNT 11 // CELL_COUNT + 4(four corners) // xy = cell position, z = data index, w = select animation float4 _CellState[DATA_COUNT]; float3 hue_to_rgb(float h) { h = frac(h) * 6 - 2; return saturate(float3(abs(h - 1) - 1, 2 - abs(h), 2 - abs(h - 2))); } float hash(float2 st) { float3 p3 = frac(float3(st.xyx) * .1031); p3 += dot(p3, p3.yzx + 19.19); return frac((p3.x + p3.y) * p3.z); } float noise(float2 st) { float2 i = floor(st); float2 f = frac(st); float a = hash(i); float b = hash(i + float2(1.0, 0.0)); float c = hash(i + float2(0.0, 1.0)); float d = hash(i + float2(1.0, 1.0)); float2 u = f * f * (3.0 - 2.0 * f); return lerp(a, b, u.x) + (c - a)* u.y * (1.0 - u.x) + (d - b) * u.x * u.y; } float linework(float2 st) { float a = atan2(st.y, st.x); float d = noise(float2(a * 120, 0)) + smoothstep(300, 50, length(st)); return 1. - saturate(d); } float4 voronoi(float2 st) { float cellIndex = 0, dist = 1e+9; float2 cellPos = 1e+5; [unroll] for (int i = 0; i < DATA_COUNT; i++) { float2 p = _CellState[i].xy; float2 q = st - p; float d = q.x * q.x + q.y * q.y; if (d < dist) { dist = d; cellPos = p; cellIndex = i; } } dist = 1e+5; [unroll] for (int i = 0; i < DATA_COUNT; i++) { if (cellIndex == i) continue; float2 p = _CellState[i].xy; float d = dot(st - (cellPos + p) * 0.5, normalize(cellPos - p)); dist = min(dist, d); } float3 color = 1; // セルの色 float dataIndex = _CellState[cellIndex].z; color = hue_to_rgb(dataIndex * 0.1) + 0.1; // 集中線 color = lerp(color, 0, linework(st - cellPos) * _CellState[cellIndex].w); // 四隅の色 color = lerp(color, hue_to_rgb(cellIndex * 0.1) * 0.6, step(CELL_COUNT, cellIndex)); // 境界線 float border = smoothstep(0, 13, dist); color = lerp(0.1, color, smoothstep(0.8 - 0.07, 0.8, border)); color = lerp(1.0, color, smoothstep(0.5 - 0.07, 0.5, border)); return float4(color, 1); }
タップ判定
次は、タップ判定についてです。
まず、タップした位置から一番近いセルの情報を取ってきます。ボロノイ的には「一番近いセルをタップしたことにする」で十分だと思います。
もし、境界線の上は反応しないようにしたいという場合は、シェーダーで境界線を引いた時と同じ考え方で、C# 上で境界線の上かどうかを判定して、境界線上なら無視するという実装をします。
今回は境界線も考慮できるタップ判定にしました。
void IPointerClickHandler.OnPointerClick(PointerEventData eventData) { if (eventData.dragging) return; RectTransformUtility.ScreenPointToLocalPointInRectangle( rectTransform, eventData.position, eventData.pressEventCamera, out var clickPosition ); var cellState = scrollView.GetCellState() .Select((s, i) => ( index: i, dataIndex: Mathf.RoundToInt(s.z), position: new Vector2(s.x, s.y) )); // タップ位置から最も近いセルの情報を取得 var target = cellState .OrderBy(x => (x.position - clickPosition).sqrMagnitude) .First(); // 境界線からの距離を計算 var distance = cellState .Where(x => x.index != target.index) .Min(x => Vector2.Dot( clickPosition - (target.position + x.position) * 0.5f, (target.position - x.position).normalized )); // 境界線の上なら処理を中断 const float BorderWidth = 9; if (distance < BorderWidth) return; // タップしたセルを選択状態にする scrollView.SelectCell(target.dataIndex); }
終わりに
FancyScrollView とシェーダー芸を組み合わせることで、スクロールビュー表現の幅がさらに広がります。
今回のメタボールとボロノイのコードは、FancyScrollView のサンプルとしてリポジトリに公開しているので、ぜひ色々遊んで見てください。