【Unity】スクロールビューでもシェーダー芸がしたい!

2019/06/19 に開催された KLab TECH Meetup #4 で「FancyScrollView x Shader」というタイトルで登壇してきました。

techplay.jp

内容は、自作のスクロールビューライブラリ「FancyScrollVeiw」を使って、シェーダー表現を取り入れたスクロールUIを作るというものです。

f:id:setchi_q:20190623191102g:plain

発表資料はこちらです。 docs.google.com

この記事は、発表内容をブログ向けにまとめたものになります。

FancyScrollView?

そもそも FancyScrollView とは何かというと、これは個人で開発している Unity 用スクロールビューコンポーネントで、下記のような特徴があります。

  • 自由にスクロールアニメーションを実装できる
  • データ件数を気にせず使える
    • 表示に必要なセル数のみ生成され、セルはリサイクルされる
  • スナップに対応&Easing指定も可
    • 様々な手触りのスクロールUIが作れる
  • 無限スクロール
  • セルとスクロールビュー間でメッセージのやりとり
  • 特定のセルにスクロールやジャンプ
  • 細かいスクロールの挙動設定

FancyScrollView は GitHub か Asset Store からダウンロードできます。

github.com

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 の値をその時のセルの見た目に変換するような実装をしておきます。

f:id:setchi_q:20190623192714g:plain
0.0 ~ 1.0 の値をセルの見た目に反映

次に、スクロールビューのインスペクタでセル同士の間隔を指定します。この例では間隔は 0.15 となっています。

f:id:setchi_q:20190627234210p:plain
セル同士の間隔を指定

間隔を指定すると、スクロールビューがこの間隔ずつズラした位置をそれぞれのセルに渡して、先ほどのセルの UpdatePosition メソッドの実装によって、このようにセルが並びます。

f:id:setchi_q:20190627233735p:plain

画面を埋めるためにセルがいくつ必要か とか それぞれのセルがどの位置にいるか というのは FancyScrollView が計算して、スクロール位置によってよしなにセルをインスタンス化したりプーリングしたりしてくれます。

こういう仕組みで、自由度高めにスクロールの UI を作ることができるようになっています。

ライブラリとしては、この仕組みを提供しているだけで、演出とかは、使う人が自由に実装してください、というスタンスになってます。

そのため、そもそもセルが uGUI の要素である必要すらなくて、実装によっては 3D モデルが複雑にスクロールするような UI など、工夫次第で自由に作ることができるようになっています。

シェーダー表現を取り入れたスクロール

スクロールビューでもシェーダー芸がしたい!ということで、シェーダー表現を取り入れたスクロールUIのデモを二つ作りました。

f:id:setchi_q:20190623194721g:plain
メタボール

f:id:setchi_q:20190623195033g:plain
ボロノイ

実際に動くものはこちらから遊べます。

Unity WebGL Player | FancyScrollView

メタボールの解説

まずはメタボールのスクロールビューから解説します。構造は、大きく分けると セルのレイヤー背景のレイヤー の二つに分かれています。

f:id:setchi_q:20190623195530p:plain
セルと背景の二枚構造
セルは、Animator ベースで動きを制御しています。

そのセルの位置をリアルタイムにシェーダーに共有して、フラグメントシェーダー側で背景の Image にメタボールを描画するようになっています。

シェーダーにセルの状態を渡す

メタボールを描画するには、まず、シェーダー側にセルの位置や表示しているデータの Index など、セルの状態を渡す必要があります。

これには、FancyScrollView の Context という仕組みが便利なので使います。

Context は、一言で言うとセルとスクロールビュー間の橋渡し的な役割をするオブジェクトで、Context 経由で メッセージのやり取り何らかの状態の保持 が自由にできるようになっています。

f:id:setchi_q:20190623195835p:plain
セルとスクロールビュー間で自由にやり取りができる

今回は、この仕組みを使って、それぞれセルの情報を一箇所に集約して、シェーダーに共有するような実装をしています。

f:id:setchi_q:20190623200229p:plain
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 座標 に基づいています。

f:id:setchi_q:20190623201049p:plain

シェーダー内で 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;
}

これで、試しにフラグメントシェーダー内でセルの位置をもとに縁を描画してみると、確かにセルの位置に合わせて円が描画できました。

f:id:setchi_q:20190623201713p:plain
uGUI セルの位置に合わせて円を描画できた

// 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 に代表的な数式が載っています。

f:id:setchi_q:20190623201927p:plain
メタボールの代表的な関数

これを二次元バージョンに変形して使うことにしました。

f:id:setchi_q:20190623202243p:plain
数式をコードに落とし込んで…

f:id:setchi_q:20190623202233p:plain
適当な明るさを閾値として色を二色に塗り分ける

このままだと絵としてさみしかったので、少しディスタンスフィールドを加工して絵を調整しました。

f:id:setchi_q:20190623202237p:plain

Metaball.hlsl

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;
}

背景がタップされたら、このメソッドを使って、まずその位置がメタボール内に含まれているかどうかを調べます。

f:id:setchi_q:20190623203904p:plain
タップ位置がメタボール内に含まれるか調べる

この時点でメタボールの外側にいたら、ヒットしてないことが確実なので、処理を中断します。

もし、メタボールの中にいたら、今度はどのセルが一番近いかを調べて、そのセルをタップしたことにします。

f:id:setchi_q:20190623203914p:plain
どのセルが一番近いかを調べる

この図でいうと、タップ位置が、緑色の 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 によって色分けすることで、このようにボロノイ図を作ることができます。

形を整えるために、セル以外にも四隅に点を配置してこのような絵になってます。

f:id:setchi_q:20190623204503p:plain
各ピクセルから最も近いセルの Index によって色分け

今回はさらに、セルの境界線も描画したかったです。境界線がかけると、このような絵以外にも、いろいろ表現の幅が広がるためです。

f:id:setchi_q:20190623204615p:plain
セル同士の境界線を描画したかった

単純にセルの位置を元にしたディスタンスフィールドはこのようになっていて、そのままではまっすぐな境界線を引くことができません。

f:id:setchi_q:20190624055642p:plain
セルの中心位置からのディスタンスフィールド

欲しいのは、このように境界線からのディスタンスフィールドです。

f:id:setchi_q:20190624055638p:plain
境界線からのディスタンスフィールド

境界線を描画

どのように境界を描画するかを説明します。説明のためにセルの位置を黄色い点で可視化しています。

f:id:setchi_q:20190623205836p:plainf:id:setchi_q:20190623205905p:plainf:id:setchi_q:20190623205929p:plainf:id:setchi_q:20190623205945p:plain f:id:setchi_q:20190623210009p:plainf:id:setchi_q:20190623210033p:plain

これで、他のセルに対する境界 との距離がわかるようになったので、全てのセルに対して同じ処理をして、最短距離を採用します。

コードにするとこのようになります。

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);
}

f:id:setchi_q:20190623210638p:plain
欲しかったディスタンスフィールドが計算できた

これで、欲しかったディスタンスフィールドが計算できました。

あとは、ディスタンスフィールドを加工して色分けして、さらに、セルの選択状態もシェーダー側に共有することで、選択中のセルに集中線を入れて遊んだりしていました。

集中線も、極座標とノイズを使ってシェーダーで書いています。

f:id:setchi_q:20190623195033g:plain

シェーダーコードの全体像です。

Voronoi.hlsl

#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# 上で境界線の上かどうかを判定して、境界線上なら無視するという実装をします。

今回は境界線も考慮できるタップ判定にしました。

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()
       .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 のサンプルとしてリポジトリに公開しているので、ぜひ色々遊んで見てください。

github.com