Imaginantia

思ったことを書きます

記録 251020 draw の V の解説

phi16.hatenablog.com

こちら ↑ に全体像は書いたので後は細かいメモみたいなものを色々書いていきます。

目次

  • 計算系
  • 文字系
  • 音解析
  • fotfla さんとの協調
  • UI
  • 操作系

ちなみに Unity 6.0 URP で、1アプリケーションです。

計算系

基本的にほぼ全て Compute Shader で出来ています。配置された文字のデータがホスト側に来ることは一切ありません。

2次元の方

  • 以下を毎フレーム数回繰り返す
    • 空き領域バッファ (中央) から候補矩形を探す
    • この時点では候補情報が散らばってるので mipmap を使って圧縮する
    • これを参照してパーティクルバッファ (左) に書き込む (spawn させる)
    • これを参照して空きじゃなくなった領域を塗り潰す

こんな感じです。どんな経緯でこうなったかはちょっと自分でもわからないので書けません。もうちょっと動作について細かく書きます。

候補矩形の探索

まず各空きピクセルは、「自分が矩形の左下だった場合に構成できる最大の矩形」を計算します。最大というのは非自明なのですが、実際には :

  • 自分が左下となるような最大の正方形を計算する
  • その正方形をできるだけ右と上に伸ばしてみて、長く伸ばせた方を採用する
  • その状態でさらに上または右にできるだけ伸ばす

という方法でそれっぽい矩形を探しています。最初に正方形を探してしまうのでこれによって面積最大の矩形が取れるとは限りませんが、別に最大を取る必要はないので問題ありません。

探す方法は二次元累積和と二分法です。一応書いておくと :

  • 埋まってるセルを 1、空いてるセルを 0 とする
  • Parallel Prefix Sum をやると「左下 (0,0)、右上が自分となる矩形の中に埋まってるセルが何個あるか」を知ることができる
    • なんか強そうなページですが実際のコード (Accum関数) は10行くらいです (それを log N 回実行する) (N = 256)
  • これ ↑ を使うと任意の矩形領域内に埋まってるセルが何個あるかが O(1) で計算できるようになる
  • ↑ を使って正方形の大きさで二分法すると O(log N) で最大の正方形が見つかる
  • 同じことをやれば上/右にできるだけ伸ばす処理を O(log N) で実行できる

わかりたい人向けコード

[numthreads(BS,BS,1)]
void AccumInit(uint3 id : SV_DispatchThreadID)
{
    int2 j = id.xy;
    float4 c = _Input[j];
    c.r = c.w > 0 ? 1 : 0;
    _Output[j] = c;
}

int _LoopIndex;

[numthreads(BS,BS,1)]
void Accum(uint3 id : SV_DispatchThreadID)
{
    uint2 j = id.xy;
    float4 c = _Input[j];
    uint d = 1 << _LoopIndex;
    bool2 hasBit = (j & d) == d;
    int2 base = j / d * d - 1;
    if(hasBit.x) c.r += _Input[int2(base.x, j.y)].r;
    if(hasBit.y) c.r += _Input[int2(j.x, base.y)].r;
    if(hasBit.x && hasBit.y) c.r += _Input[base].r;
    _Output[j] = c;
}

float area(int2 p, int2 s) {
    int c = 0;
    p = max(p, 0);
    int2 corner = min(p + s, Size) - 1;
    c += _Input[corner].r;
    if(p.x > 0) c -= _Input[int2(p.x - 1, corner.y)].r;
    if(p.y > 0) c -= _Input[int2(corner.x, p.y - 1)].r;
    if(p.x > 0 && p.y > 0) c += _Input[p - 1].r;
    return c;
}

これによって各ピクセルが矩形候補を持つ状態になりました。

次に矩形候補の中で最も大きいものを探します。これは普通の Parallel Reduction です。モノイド演算ならなんでもいいので、正確には適当な順序の下での最大を探すようになっていて、「面積」「x, y 座標」「中心からの距離」で重み付けできるようになっています。

…という処理をすることで、頑張って何度も Compute Shader を回してやっと 1 個の矩形を配置できるようになります。

そんなに時間は掛からないので何度も回しても全然問題ないんですが、もうちょっと高速に配置したい気持ちもあります。そこで Quadtree 方式も実装しました。

 

Quadtree 方式では、空間を好きな場所で2つに分割します。という処理を 2 log N 回やります。ただし途中で現在の空間内が全て空き領域だった場合、そこで分割を停止し、候補矩形として確定させます。

分割位置は自由に設定できるので、完全中央か完全ランダムかの間のパラメータがあったりします。

細かいことを言うと、各ピクセルは常に自分が含まれている分割空間のことをチェックしていて、その空間が全て空き領域であったとき、かつ自分がその領域のちょうど左下にいたら、候補矩形として記録する、という処理を行っています。

これによって一瞬で被らない候補領域が大量に生成できます。被らないという条件を守るのがオリジナルの配置方法だと結構難しくて (後半になるとほぼ被らないんだけど、その保証がない)、Quadtree はちょっと見た目がごちゃっとなるけど代替手法としては悪くないって感じです。

候補矩形を spawn させる

候補が 1 個でしかも Parallel Reduction で計算したときは場所がよくわかっているのでそこから値を拾えばいいんですが、テクスチャ内に複数散らばってるといろいろと大変です。

というわけで mipmap を生成します*1。mipmap は「テクスチャを構成する各四分木ノードについて、その領域の全ピクセル値の平均を持つ」という構造で、これを使うと「Z-curve に沿って index を与える」ことができます。私は至るところでこれを使っています。知ったのはコレです。

これによって「spawn 数を数える」「左下に順番に並べておく」ができます。

文字は自前のパーティクルシステムで管理していますが*2、まぁ spawn するのは簡単です (空いてる子たちが順番に spawn バッファを読みに行けばいい)。問題は各矩形候補が実際に空き領域を塗らなきゃいけないという方で。

「各ピクセルが全矩形を調べて自分が塗られるべきか判断する」のは大変 (= 遅い) です。ので、逆に、普通に矩形を描画することにしました。

世の中には CommandBuffer.DrawMeshInstancedIndirect という命令があって、描画の instance 数を GPU 側のバッファから決定できます*3。今回初めて使った。

これを使って「spawn 数だけ矩形を instancing して、各矩形はバッファを見て所定の位置に移動、後は普通にレンダリングするだけ」で塗りつぶしが完了します。そうですねという感じです。

今更だけどこれうまく使えば「文字の隙間もちゃんと埋める」っていうのができますね。考えてなかったけど*4

 

以上で、やるべき処理が全部完了するので、後は何度も実行すればどんどん文字を生やしてくれます。ちなみに文字は画面外に行くと消失するようになっています。

補足

パーティクルとなった文字群を左に移動させつつ、「移動量が指定量を上回ったときに空き領域バッファ自体を移動させる」処理を入れると、滑らかに移動しつつ無限に文字が生成されるみたいな状態を作ることができます。

ただこれ拍系の処理と相性が悪かったり生成タイミングをズラさなきゃいけなかったりと調整が難しくて omit しました。かわいいので使ってあげたかったな~。

ズームイン/アウトするやつも同じ仕組みです。拍に合わせる分にはなんとかなります。log-polar mapping とか思い出しますね。

そういえばこれはちゃんとレイヤー分ける前で文字が UI にまで映り込んじゃって、加算合成だった上に Z-fight してた結果めっちゃかっこよくなったやつ。存在しません。

ちなみに fotfla さんのに被らないようにする処理はとてもシンプルで、もらったテクスチャを空き領域バッファにベタッと貼るだけです (空いてないことにすればいいだけ)。文字の回転/拡縮とかもあるのでその辺はうまいこと座標系を合わせています。

1次元の方

こっちは本当にシンプルです。書いた通りで :

  • 一度シーンの処理 (Compute Shader) を走らせて各ピクセルに情報を格納する
  • 横方向に対して Parallel Prefix Sum しつつ Parallel Reduction で幅の累積和と高さの最大値を計算する
  • 縦方向に対して Parallel Prefix Sum して高さの累積和を計算する
  • 縦横の累積和があるので各文字が全体の中でどこにあるかがわかる

余談を書いておくと、本番の前々日までこっちはテスト実装以外何もなくて。何故ならそれまで後述の音解析をやっていたからなんですが。で、手元にある2次元文字埋めシーン、確かに面白いことは面白いけど絵的な面白さが何もなくて全然ダメだなーってなってました。実際 fotfla さん側の実装と組み合わさったのは確か当日なのでポテンシャルが全然わかってなかった感じなのですが…。

で、弄ってても何にもならないので一度籠もって考え直したときがこれです。

この時までは2次元シーンがメインで、1次元側がサブのイメージだったのですが、むしろ動きを作るのは1次元側のほうが得意なので、そっちをメインにした方がいいだろうと思いました。全てが遅い。

シーンのアイディアは (イントロ含めて) いくらかあったのでちまちま作り始めたのですが、拍合わせ系の実装が非常に煩雑になってしまいました。その時は2次元シーンの延長で組んでいたので状態保持ベースだったのです (つまり拍始まりの条件を通ったときに文字を切り替える、みたいな)。

そうするとシーンを増やすのが本当に大変で、これは変えたほうがいいな~って思いつつ fotfla さんに相談した結果、状態保持を全部捨て、拍ごとのシーンを補間するという方向性に切り替えました。ちなみに fotfla さんは LUV BUGS のときの VJ で同じようなことをやってました。

こんな感じです。

CTile Scene1(uint2 coord, float fT, int iT, int subIndex) {
    CTile ct = (CTile)0;

    ct.size = 1;
    ct.ci = rand(float2(coord.x/256.0, coord.y/256.0 + _Seed)) * 26 + 10;

    float tu = rand(float2(_Seed, iT)) * 2 - 1;
    float4 coeff = float4(4, lerp(0.1, 0.3, abs(tu)) * sign(tu), 1, 3);
    if(iT % 2 == 1) coeff.y += 1 * sign(tu);
    float v = cos(dot(float4(fT, coord.xy, iT), coeff)) * 0.5 + 0.5;
    ct.size.x = v;
    if(ct.ci == 10 + iT % 26) ct.whiteImpact = 1;
    if(subIndex > 0 && (coord.y + iT) % 3 == 0) ct.size *= 0.5;
    return ct;
}

これによって大きな簡略化ができてシーンを増やせた上、Modifier の仕組みを思いついてなんとか嵩増しすることもできました。ちなみに Modifier の情報は ScriptableObject として存在していて、お陰で追加も編集も簡単です。この辺についても本当に fotfla さんに感謝です。

 

シーンは2次元系が18個、1次元系が18個となんだかんだバランス取れた感じになりました (ちなみに2次元系のシーンも ScriptableObject で管理しています)。

とは言えどっちも突貫工事で増やした感じなのでもっと細かい調整入れられたら良かったですね…。

文字系

どんな字体にしようかのイメージはもともとあって。それをどのようにデータ化するか、が問題でした。具体的には「どこをパラメータ化するか」「各文字デザインをどう作るか」です。

結論としてはこうなりました :

この画像はそのまま内部データ生成用に使ってるやつです (背景入れたり拡大したりしましたが)。上の絵たちはイメージ図の名残り。

5x5 領域に区切っていて、各セルは直線か曲線が複数組み合わさった図形をしています (中央が抜けてると曲線)。こういう感じです。

上下左右で一貫していればセルの大きさ・文字の太さは自由に決められるので、かなり flexible です。

こうして作った文字データを Houdini に食わせて一旦動きを検証して (こういうプロトタイピングにちょうどいいんですよね)、テキスト形式で吐いて Unity 側で読んで、Shader Global に置いておきました。後はそれを読んで distance field をこねると文字が出せるという感じです。

そのままだと全パラメータ (各セルの大きさ・線の太さ) を与えないといけないわけですが、ちょっと大変なので :

  • 全セルは同じ大きさだと仮定して
  • 線の太さパラメータが与えられたときに文字の横幅が何になるかを計算する為のデータがあったので
  • 与えられたサイズにちょうど文字が収まるようなセルの大きさを計算して
  • それを使って文字を出す

ようにしました。これによって気軽に文字が出せるようになった反面、横線の位置を上下にズラすみたいな自由度が無くなりました。難しいですね。

で、これまでの話だけだと「並べる」ができません (テキストはまだ出せません)。実際のところ文字を丁寧に並べているのは UI 部分だけなのですが。

UI が表示したい文字情報は全部 GPU に送っていて、毎度のごとく累積和で表示すべき現在位置を計算しています。で、そこに沿ってポリゴンを置くというあるある処理をやっています。

ただし、特定の文字の組み合わせの場合は幅を短く申告します。kerning です。

無いとすごい間の抜けた感じに見えるのでこれは必須です。

まだ詰めが甘いように見えるかもと思うんですが、これ太さパラメータによって絶妙に位置関係が変わるので難しくて… (variable font って難しいんだなぁと思いました)。組み合わせはシェーダ側でハードコーディングしています。そんなにパターン無かったので*5

ちなみに FiTi のときだけ i の点が消えます (ligature み)。この組み合わせが本番で表示されたことはありませんが…。

この辺で気づくことがあって。「並べる」というテーマ、当たり判定がでかすぎる。私の UI (左側) も「矩形を並べる」というルールに則って作っています (UI の細かい話は後でします)。なので簡略化できなかったというか、意思を全うする為にはやらなきゃいけなかったというか。やっぱスライドパズルもやってあげたかったねぇ。

文字間のスペースも勿論弄れるので、よくあるモーショングラフィックにありがちな、テェーーーンって文字間が広がっていくやつもやってみたかったですね。

概念の当たり判定がでかいので、作っているとき外出すると The Witness よろしく頭の中で看板の文字うにょうにょなったりレンガ模様がスクロールしたりで面白かったです。普通にこれだけで VJ ネタになる気がします。

今回アルファベットを使った理由は UI に出てくるから以外の理由はないです。ちょうどその頃 WSM やってたみたいな話は影響あると思いますが。シンプルかつ意味が無いかつフォントが雰囲気の情報を持つという意味でちょうど良かったと思いました。なのでフォント (線幅) のコントロールは自動化せずに自分で触るようにしていたんですが、うーん、むしろ勿体なかったのかもしれない。

 

そういえば意味が無いシンボルを 64 個用意したりもしました。アルファベットだと乱雑さがあまりないのでそういう撹乱みたいなのが欲しかったのだと思います。ちなみに msdf です。

穴空き系の図形がたまに msdfgen と仲悪かったりしたので分割してくっつけたりしました (微妙に artifact が出ています)。

こういうのもうまくいかなかったのですがちっちゃいのでバレないことを祈りました。

ちなみに元ネタ?の1つはコレ ↓ です。この方向性もまた面白そうで…。

www.youtube.com

あともう1つは何かでうっかりメモリ書き込みが完了してないときに読み出して発生させたグリッチがこんな感じでかわいかったというやつです。

音解析

generative と言えば audio reactiveみたいな風潮があったりなかったりすると思います。今回最初は audio reactive しようと思ってたんですが最終的には使いませんでした。代わりに bpm の自動検出をやらせています。

仕組みは次です :

  • keijiro/Lasp から Spectrum をもらう
  • パワースペクトルに変換した上で周波数方向に累積和を取る
    • (エネルギーにすると比例尺度になるので足し算ができます)
    • これによって「指定した周波数範囲のエネルギー」が計算できるようになる
  • キックの落ちていく部分を2オクターブ分、低音部分を1オクターブ分、あとハット部分を1オクターブ分、追跡する
    • 周波数帯域は色々実験して決めました
    • UI の下半分がそれぞれの検知履歴です
  • エネルギー値のローパスを基準として強弱どっちにいるかをそれぞれ判定して、3つの bool 値を得る
    • こうすると threshold が音の大きさに依らないので何もしなくても安定します
    • これが UI 左上の色です (RGB)

で、これによって「毎フレーム*6に3つの bool 値を得る」状況になるわけですが、これを使って :

  • 各周波数候補 (中央±15bpmの範囲、64通り) について、次を計算する :
    • これまでの履歴をその候補 bpm で区切る
    • 「最新の一拍分」と「n拍前の一拍分」の系列の内積を取る (Parallel Reduction)、これを8-1拍分行って総和を取る
    • この計算結果 (類似度) は bpm が真値に近ければ近いほど大きくなるはず
  • 類似度が最も高い候補 bpm今度は中央として、再度中央±4bpmの範囲で同じ処理を動かす
  • 類似度が最も高い候補 bpm を記録する

で、これによって「毎フレーム候補 bpm を得る」状況になるわけですが、これを使って :

  • 過去の候補 bpm 系列の平均と分散を Parallel Reduction で計算する
    • bpm そのまま使っちゃったけど s/beat のほうが良かったかもなぁ (今更)
      • ほぼ線形だろって思ってたけど
  • 2σ 以上外れた点を無視してもう一度平均と分散を計算する
    • UI 右上の図で縦長ラインになっちゃっている部分が無視した点です
  • これによって得た σ が 1bpm 未満、かつ無視した点が半分を超えなければ、採用
    • UI 右上の図で全体を覆う水色が ±2σ の範囲です
    • 得た bpm 値は AsyncGPUReadback によってホスト側で拾います

という工程によって bpm を算出しています。やりすぎた気がします。でもお陰で精度はかなり高いです。

推定が間違ってるときは系列の区切り位置を間違えているのでこのラインが斜めになってすぐわかります (こういうときは分散も高いのでちゃんと採用されません)。

ちなみに変わったタイミングでキックが入る曲でも4拍周期だったりハットが生きてたりしてなんだかんだそれっぽい結果を返してくれます。外れすぎると勿論働きませんが… (bpm 自動推定のオンオフもできます)。

 

副作用としてキックとハットについての居るか居ないかの情報が取れたのでこれを audio reactive に使おうかなとか思ってたんですが、モニタ遅延があるのと、これ単体で見るとあんまり精度が高くないということで特に使いませんでした。実際 bpm だけでなく「拍始まり」の検知に使おうと最初は思ってたんですが、ちょっと信頼性が足りなかったので手動でやることになりました。

解析系の実装自体はそんなに時間掛かってないんだけど (Parallel Prefix Sum はもうライブラリ化してます)、いろんな曲で動くか確認する方に時間が掛かった気がします。

fotfla さんとの連携

折角1アプリケーションでやっているので色々混ぜたいと思っていました。そこまで面白いことはできていないですが (「被らないようにする」のはまぁ面白い側だと思っているけど)。

例えば色は、色相環の一部を切り取ったものを共通のパレットとして使っていて、その範囲を決めるのは fotfla さんの操作になっています (8種類あるみたい)。で、そのパレットを離散化したり (たまに単色/二色になってたと思います) 色の割合を偏らせたりする機能 (ほぼ使ってない気がする) があって、それは私が操作しています。これは偶然そうなっただけですが。

片方の色相を大きくズラすとまた印象変わるだろうとか思っていたんですが、うまくコントロールするのが難しいということで実装しませんでした。

 

あと Stencil が入ってます。

もともと fotfla さんが Stencil を使って領域を塗っていたので、折角なのでそれを貰って (別に何もする必要はない、後段で描画するだけ) Stencil が入っているときは輪郭線だけ描画するようにしました。正確には fotfla さんの方は「mod4で2以上*7」、私は「mod2で1」でエフェクトが掛かるので違いが出ていて結構複雑に見えるかなと思います。

fotfla さんが領域を塗ってないときも Stencil を書いてもらうようにしたのでこうなります。

余談として…「カメラを動かすか」みたいな話題はあって。動かしたほうが勿論 dynamic に見えるし色々面白いだろうとは思っていたのですが、結局二人ともシーンをかなり平面ベースで組んでしまっていて、カメラ動かすと破綻する可能性がちょこちょこあったのでやめておこうとなりました。

文字たちを出しているメッシュは実は Quad ではなく Cube なので伸ばしたりするのは想定してました。

実際文字たちはメッシュとしてちゃんと分かれているので、リボン形状の上を文字が走ってそれを追跡するみたいな、ありがちなやつもやりたいな~とか思ってました。ぱーって散らしたり集めたりとか、色々し放題ではあります。やりたい放題すぎるとコンセプトから離れるというのもあってそこまでするつもりはありませんでしたが… (まぁ散っているからこそ並べることに意味があるみたいな話は勿論あって、それはスライドパズル案がかなり良い接点になっていたはずでした)。

UI

これ (左右端) の話です。

まず何故 UI があるのかという話をすると :

  • 「並べる」という概念の実践例がほしかった
  • generative 系で UI が出るのはまぁ context に則っている
  • 単に私が操作する為の UI が必要だった

UI (user interface) の在り方っていくつかあって。例えば見せる用 (user = 観客) と操作する用 (user = 自分) で分けるのも自然な話で、何故なら目的が違えば当然 UI の設計も変わるからです。実際最初はそうするべきだと思っていました。が、2つの UI を設計する時間が無いので、1つで全てを兼ねることにしました。なのでこれは使うために作られた UI です。

私が左側のサブスクリーンをちょうど使ったので、fotfla さんは右側を UI に使ってくれました。ちなみに fotfla さんは midi コン操作なのでほぼ見せる専用の UI だったはずです。私は表示機能の無い板 (後述) をコントローラにしていたので、結構 UI を見て操作しています (正確には、現状を確認する為に使っています)。

もともとつけようと思っていた機能として「自分が触っている feature のフレームを拡大する」というものがあって。つまり「私が何を操作しているか」を伝わるようにしようかなと思っていました。が、結局触っているのって一瞬だったり、それによって UI ががちゃがちゃ動きすぎてもうるさいかなと思ったので実装はしていません。

一部を拡大する機能自体はあって、小さくて見えない時とかに使おうと思っていました。実際は PC 画面を直接覗き込んでいたし拡大機能がミスで発動したりして困っていた*8ので要らなかったっぽいです。

 

実装はかなり変なやり方をしています。要件として「フレームの位置を GPU 側で自由に決定できる」というのがあるので (拡大する為)、UI 表示に関わる全オブジェクトに対して自由に vertex transform を噛ませられるようになっています。

シーン上では自由に並べておいて、実行すると特定の位置に並ぶように頂点が移動される感じです。

で、その部分をカメラで撮ってメイン画面に上から overlay する*9、という処理をしています。

もうちょっと詳しく書くと :

  • フレームの子にある全ての MeshRenderer に、何番目のフレームに属しているかを表す数値を入れた MaterialPropertyBlock を突っ込んでおく
  • フレームの local transform を reset して全て UI Root の位置に揃える
    • UI Root の位置も MaterialPropertyBlock に入れておく
  • フレームの位置・スケールをいい感じに Compute Shader で計算しておく
  • 各 MeshRenderer に割り当てられているシェーダは ↑ を参照して自身が居るべきワールド座標に頂点を移動させる

これで普通のメッシュが置けるようになります。で、文字の方もまた厄介で (並べないといけない為) :

  • 自作の UIText component が付いている MeshRenderer をテキスト表示だと認識する
    • この component には最大文字長や表示したいテキストなどが格納されている
    • この MeshRenderer は (stub なので) オフになる
  • 各 UIText に index を割り振り、それぞれにテクスチャの1行分を与える
  • 各 component は自分が表示したいテキストを uniform array に書き込む (更新があれば)
  • 累積和を取って各文字が居るべき場所を計算する
  • Quad をたくさん instancing して各文字のところに置く

文字位置 (X) を計算する為のバッファと UI フレームの位置 (Y) を計算する為のバッファは実は共通で、一緒に累積和を取ってくれます。

幅が狭い文字 (i とか l とか r とか) は R channel が小さめの値になってるのがなんとなくわかるかと思います。G channel に和が入っていて、大体 1 を超えています (超えてないのは - 1文字のところだけですね)。B channel が文字 ID だったかな (0 の内部 ID は 0 です)。

最終的にフレームは「全長が所定の縦幅ぴったりになるようにスケールする」処理が入っていて、個別にスケール率を掛けても上端下端が揃います。

あと各フレームは「右端を司る UIText」を高々1個持てるようになっていて、その UIText の右端 (+ offset) がフレームの横幅を超えるときはフレームを延長します。TapTempo が結構元気にサイズ変わるんですが一切使ってないのでわからないですね。Modifiers くらいかな。

 

まぁ演出には何も影響無いわけですが、自分の中では (特にテキスト配置が) 一回やってみたいものだったので勝手に満足してました。本編と処理が共通なのも面白いし。

それはそれとして終わった後でこれ何の意味だったの~って聞かれるのは結構嬉しいですね。Modifiers は最後の突貫工事で追加したやつなので一番ちゃんと情報が出てなくて。わからないのもさもありなんです。

操作系

シーンパラメータ

作ってるときのメモ画像を適当に貼っておくんですけど。こう…拍が強くないときと強いときで大きく2状態あって、その間の遷移があって、みたいなことを考えてました。

ドロップの瞬間ぱって手を離したら状態が切り替わるイメージです。その時に勝手にシーンもランダムに切り替えるみたいなことも考えてました。

が、複雑な状態遷移を作ると突然の現象に対応できなくなるので困るなと思って、結局単純なスライダーに落ち着いたのでした。

最終的には3つのスライダーがあって :

  • 滑らかな時間変化
  • 拍エフェクトの強さ
  • 拍エフェクトが終わる速さ

後ろの2個は途中で分裂したもので。拍の動きって低音部が強いときはちょっと振動っぽいエフェクト掛けていいと思うんだけど、単にカッってなってるときはシンプルに素早く終わらせるみたいなのが欲しくて。途中まで Y 字みたいなものを考えていたけど*10インタフェースの都合*11で分裂させたのでした。これもあんまりよくなかったかもな、2次元にしたほうが良かったとかはありそう。

「滑らかな時間変化」の方は単に speed [1/s] を指定していて、この速度でシーン時間が進行していきます。これを sin とかに突っ込んでおくとだんだん速くなっていく動きが作れるというイメージです。0 にすると止まります。止まりすぎてちょっと困ってたりもしました、オフセット乗せようと思ってはいたけど修正する余裕がなかった。

内部実装的には…1次元シーンの方ではもともと拍による遷移があるので、拍エフェクトの方はそこのアニメーションに自動的に乗ります。それはそれとしてシーン定義内でパラメータを拾えるので、勝手に印象を強めたり変化を加えたりもできます。

逆に滑らか時間の方は明示的にシーンが拾わない限り適用されないので、こう、シーン次第ということになってしまっていました。これが本当にもったいなくて。案として、うまく実装すると任意のシーンに「左右ループする」動きを組み込めるので、そのスクロール位置を滑らか時間で制御することで「任意の1次元シーンで大凡の操作感が揃う」というのができた…気がしています。これもやりたかったですね。

まぁ困るポイントとしてはそれよりも拍変化を切れないことのほうが問題ではあって。拍を seed として文字を決定していたりするので、変化させないためには拍を止めるしかないのです。しかし、拍時間を調整する方法がなかった!やれば 1/8 倍とか 4 倍とか、もうちょっと小回りが効いたのではないかと思います。…とは言え単に遅くするだけだと発光エフェクトとかも遅くなってしまうので…意外と難しいという (全部遅くなってもいいんでしょうけど)。

ちなみに2次元シーンの方は滑らか時間を完全に無視しているので何もできません。むしろもともと2次元シーンの移動速度を調節する為に導入したパラメータだったはずなんですが、どうして… (滑らか移動を削ったからなんですが)。

このあたりは完全に経験不足だな~と感じます。でもその割には昔作ったパーティクルコントローラは割といい筋だったように思えました (タイミングの揃い度と位置関係の揃い度が独立して制御できるとか、追加エフェクトそれぞれの強弱がコントロールできるとか)。あのときは (VRChat なので) R³ × SO(3) を絶対指定で自由に入力できたのでやっぱりそれくらい欲しいのかもしれません。少なくともスライダーは物理式のほうがいいなと思いました。

入力

で、何故相対指定だったのかという話なのですが。

今回の入力装置は Sensel Morph というものです。もう売ってないです、かなしい。10点以上マルチタッチ認識できて、圧力もリアルタイムで教えてくれる…面白いやつです。keijiro さんが Examples を出していたりもします。実装の参考にしました (特にスレッド周り)*12

github.com

昔楽器っぽいものを作ろうとしたときのコントローラにしていたこともありました

何故これを使ったかというと、これしか持ってないから基本的に操作は「制御空間を表す」べきものだと思っていて、それを物理装置の構造によって制約されたくないのです。その点 Sensel Morph で好き勝手タッチを「解釈する」のはかなり理想に近いです。

ただ昔組んでたときは JavaScript だったので generator 式があって do 文ができてたんですが、C# だと厳しそうだったのでタッチ挙動のカスタマイズがあんまり…綺麗じゃないです。結局 OnTouchStart/Move/End くらいになってしまったのでこれもまた勿体ないことになっていた気がします。

一応中間ではこういう感じにはなってます。けどエンドがこうなってないんだよな。

public IEnumerator Run(TouchCell cell) {
    OnTouchStart?.Invoke(cell);
    while(true) {
        yield return 0;
        if(cell.isEnd) break;
        OnTouchMove?.Invoke(cell);
    }
    OnTouchEnd?.Invoke(cell);
}

まぁボタンなら OnTouchStart でいいし、相対移動なら OnTouchMove でいいわけで。作りやすい組み方にどうしても人は流れますね…。

 

根本的な話をまだしていませんでした。Sensel 側の入力情報 (とテスト用にマウスの入力情報) を受け取った時、シーンにある Sensor component たちに対して Raycast を投げて判定を取っています。

この Sensor たちは物理位置に合わせて置いてあって…というか、もともとは 5mm 単位でぴったりの位置にシールを貼ったつもりなんですが、まぁ私の手作業による誤差とかでズレるので、逆にシールの位置に対して Sensor オブジェクトの位置を移動することで合わせました。爪でちょんちょんしてキャリブレーションしました。

で、その入力*13が当たったらその Sensor が retain します。当たらなかったら以後の移動でも Raycast し続けて、今度こそ Sensor に当たったらそこを TouchStart にして retain します。つまりちょっとズレた位置に指を置いたりしても、ぐっと直そうとすればちゃんと認識します。

これによって「シールの範囲を超えて指を動かしても同じ Sensor に対して入力が伝わる」ので、見かけ以上にスライダーの可動範囲は広いです (要は板全体)。

ただ逆に言えば一度何かの Sensor にぶつかったらずっと retain されるので、隣り合ったセンサーにうっかり触れてしまうと修正できません。難しいですね。

一応各 Sensor の意味合いを書いておきます :

    • Syn: 音解析によって検出した bpm を利用する/しない
    • Tap: TapTempo 機能によって検出した bpm を利用する / しない
    • ←BPM→: 手動で BPM を細かく調整する
    • ←approx→: 音解析の候補 bpm 範囲の中央を調整する
    • delay: 拍頭合わせの遅延量を調整する (モニタとの遅延向け)
    • w/b : 画面全体の明るさを調整する (終わり向け)
    • 0|1|2|3: サブシーン選択
    • : 文字の拡大縮小
    • R, F, C, T, X, Y: Modifier のオンオフ
    • s, , : 割当無し
    • 1: 1拍シーンチェンジ (後述)
  • 紫中央
    • : 現在のシーンをリロード
    • ∘|3|2|1: 色の離散化 (∘ は連続)
    • η みたいな: 拍エフェクトの強さ
    • Ξ みたいな: 拍エフェクトが終わる速さ
  • 紫左下
    • ξ みたいな: 滑らか時間の speed
    • F: フォント (線の横幅・縦幅)
    • : タップテンポ
    • U みたいな: 色を偏らせる機能
    • 1|2/3|4: 拍頭合わせ用
  • 右下 (シーン選択)
    • 上半分が2次元系、下半分が1次元系

横長だったり縦長だったりするのは相対指定ができるものです。フォントに関しては (45度傾いてるけど) 2軸あって、真上にスライドすると縦横両方増えるので純粋に太くなります。右上に動かすと横幅だけが増えるのでおしゃれっぽくなります。

シールは100均で買ってきました、ちまちま測って切って貼ってをやりました。80mm×105mm だったのでシーン選択領域 (90mm×90mm) に足りなくて、2枚くっつけています。ちょうどこれが上半分と下半分の境界になっていていい感じです。

シールを貼っている理由は勿論どこに何があるかわかる為というのもあるんですが、感触でわかるようにするというのも (かなり!) 大きいです。とは言え…物理ボタンには負けますね。スライダーは言わずもがなだし。

本番だと暗いのでシールの文字見えるかわからないな~と思って照明を買いに行ったりもしました。思ったよりコンパクトに置けてちょうどよかったです。

シーン切り替え

シーン遷移はちょっと変な仕組みが入っているので書きます。

  • シーンの部分を触るとシーン遷移を予約する
  • 4拍周期の始まりの瞬間にシーンが遷移する
  • ただし、緑の1のボタンを押下してるときは、1拍周期でシーンが遷移する

つまり2拍目あたりで次の遷移がありそうな感じがしたときに、シーン変化の予約ができます。また、毎拍変化したら面白そうなときは1ボタンを押しっぱなしにしつつシーンを触っていけば1拍区切りで変わっていきます。

加えて :

  • シーン遷移が予約されている状態で、1のボタンを押下し始めたとき、1拍周期にはならない
  • 代わりに4拍周期の最後0.5拍分、画面全体を黒にする

説明が難しいんですが、どっちを先に押すかで挙動が違うということです。意外と私にとっては直観的で悪くないです。

黒を入れると締まる感じがしてかっこいいのと、あと拍始まりがちょっとズレてもバレにくいみたいな効果があります。

0.5拍ではなく「最後の 2n 拍分 (n は今を超えない最大の整数)」にすればよかったかもな~とは思っています。最初の1拍の時点で1を押し始めたら2拍分黒が入る感じで。拍系の処理がバグりそうで怖かったので安全側に寄せてしまいました。

その他

拍頭合わせ

1, 2, 3, 4 のボタンがあります。各拍頭で私が手動でぽちぽち押すと拍頭が (4拍周期で) 揃います。正確には「3」を押した時点で準備が始まって、次に「4」を押すことで4拍目のタイミングが確定します。これによって次の周期の1拍目が合います。

「押した瞬間を 0 とする」だと LED 遅延でズレてしまうので 190ms くらいズラしています。

シーン切り替え直前に大抵やっていて、たまに画面がぷるってなるのはコレのせいです。次の周期が始まるまではズレを修正せずに、次の周期になってから治すみたいな処理をするのが一番良い気がします。

TapTempo

本番では使っていませんが TapTempo 機能があります。結構真面目に実装しました。

  • 最大16回分記録する
  • 8回以上押した後は、これまでの履歴の ±3σ 以内に入ってないと入力を受け付けない
  • これまでの記録に対して Theil-Sen estimator を走らせていい感じの間隔を計算する
    • ここでも累積和を取ります!
    • この手法は ChatGPT くんが教えてくれました
  • その間隔から bpm を算出する
  • 記録が8回以上ある場合はその bpm を採用する
  • 記録が4回以上ある場合は、現在の bpm との差が 20 以上であれば採用する
  • 記録が2回以上ある場合は、現在の bpm との差が 40 以上であれば採用する

要は「あまりにも差が大きい場合は即座に採用する」「それ以外は8回落ち着いて計測してから採用する」という動きをします。途中のタップタイミングがズレたり余計な1タップを入れたりしてもそこそこ安定します。

拡大縮小

(私が管理してる文字たち) 全体を拡大縮小する機能がついています (transform.localScale を弄っているだけ)。low-pass を通してるのでゆっくり変化します。拡大率は exp で掛かります。

こう、まぁ、ふにゃふにゃしちゃってダサいとは思うんですが、無いよりはいいなというか、無くて勿体ない感じになるよりはダサくやる方を選びました。

デフォルトから変えすぎると困るのでダブルタップでリセットする機能が付いています。加えて「シーン切り替えが予約されている場合にリセットをしようとすると、即時にはリセットせず、シーン変更した瞬間にリセットする」ようになっています。

が、例えば黒を入れつつこれをやるには「シーンを押す」「1を押しっぱなしにする」「拡大スライダーをダブルタップする」という流れを3拍以内にやらないといけないのでかなり無理でした。拡大スライダーが右側にあったらまだありうるかも。

「予約」自体はできると思っているので (そのあたりの感覚がそんなに間違ってないことは今回よくわかったので良かったです) 後は運用向けの機能をもっと作り込んだら思ったより自動化 (正確に言うと、やりたいと思ったことを全部やってもらう) できるんじゃないかと思っています。

Modifiers

大きく分けて3種類、全部で6種類あります。全部重ねがけできます。

  • R (rotate): 回転します
  • F (flip): 縦横を入れ替えてレイアウトします
  • 1次元シーン向け
    • C (color): 白を入れたりします
    • T (text): 文字を変えます
    • X: 横方向に伸ばしたりします
    • Y: 縦方向に伸ばしたりその他なんでもします

適用されるのは「シーンを開始した時」なので、次から激しくしようと思って Modifier をオンにしておくなどができます。UI は「適用予定かどうか」しか書いてないので、今のシーンに適用されているかは別です。

CTXY については予め定義しておいた Modifier のリストの中からランダムで選ぶようになっていて…条件とか効果とかが色々あります。Y、つまり縦方向の大きさの変化を入れると、根本的にシーンの雰囲気が変わる (高さの最大値が変化するのでレイアウトが変わる) ので、このときには光ったり文字変えたりとか色々 +α でエフェクトが掛かるようにしました。

とりあえず思っているのは、「条件」と「効果」をまず分離したほうが良かったです。つまり条件をランダムで選び、効果をランダムで複数選ぶ、みたいな組み方のほうが一貫するし面白い見た目になった気がします。

今はかなりごちゃごちゃした動きしかしていなくて、もともとの「わかる」という方向性からちょっと外れてしまっているので勿体ないです。うーむ。

ちなみに Modifier のランダム選択はシーンを開始したときに行うので、シーンリロードを押し続ければどんどん違う Modifier が選択されていきます。予定では Modifier を全部有効にした上で1を押しっぱなしにしておけば簡単にわちゃわちゃして楽しい、と思っていたんですが結局あんまりそういうことはしなかった気がします (わちゃわちゃ度はもう十分足りていて、大人しさの方が足りなかったという話)。

終わりに

長くなりました。色々やりました。やはりちょっと仕組み作りが楽しすぎたのが敗因って感じがしますね。次*14からは今回のがあるのでもうちょっとマシになる気がします。そうでもない気もします。

だいぶ大変なことになっていましたが、本当になんとかギリギリ前に立ててよかったです。どうしても私は自分でやったことのないことをやろうとする癖があるので、何かやるっていうときに大変な思いをするのは大して変わらないのですが、加えて今回は前に立つということで久々に自分の苦手を認識できて、それもまた良いことなのかもしれないと思いました。終わったから言えているだけですけど。

もうちょっと安心できる環境で色々やりたいとは思いますね。究極的はまぁワールド作っているのが一番合ってます、それはそう。音系はちょっと当分いいかなという気もしていますが。

結局今回の他のアクト全然ちゃんと見れてないのでこれから観ます (というかやっと観れます)。draw();、頑張ってるイベントだと思うのでこれからも (観客として!) 楽しみにしていきたいです。

Nhatoさん、fotflaさん、一緒にパフォーマンスできて楽しかったです。演者・関係者のみなさん、本当にお疲れ様でした。そして今回呼んでくださってありがとうございました。また感想もいろいろ頂けてとても嬉しかったです。

*1:別に Parallel Reduction しても同じですが折角便利関数が用意されてるので使っている

*2:こっちは StructuredBuffer で管理してるので mipmap ではなく普通に Parallel Prefix Sum して compaction してます

*3:文字の描画の方は普通に Graphics.RenderMeshPrimitives で 65536 instances 投げているので別にここでもそれで良かったような気がしなくもない、まぁやってみたかっただけかも

*4:後から文字のパラメータ変えたら破綻するから

*5:斜め線が無いのでかなり単純

*6:実際には pixels per second みたいな値で変換してます

*7:今解説書くためにシェーダ見に行って初めて知りました

*8:別のボタンを押そうとしているのに拡大機能に吸われてしまった

*9:PostProcess を通したかったので Canvas は使っていません

*10:左上に行くと振動強め、右上に行くと素早く終わる

*11:絶対指定ではなく相対指定が基本なので、途中でトポロジーが変わるような移動は辛い

*12:keijiro さんのバージョンだとデバイスを抜き差しすると認識しなくなるので、そのあたりの実装を入れました

*13:ちなみに圧力が一定以下の場合は無視しています

*14:無い