進研ゼミ小学講座『情報発信局』の字幕制作に Furigana Studio が採用されました

個人開発している Furigana Studio が、進研ゼミ小学講座『情報発信局』の字幕制作に採用されました!

ご厚意でエンドロールに名前も掲載してくださっていました。感謝です😭

Furigana Studio はプロトタイプ開発から数えると開発開始からそろそろ2年が経ちます。ときには4ヶ月近く開発が止まっていることもありましたが、細々と作り続けてきて良かったなと思える瞬間でした。

プロトタイプの Furigana Studio

現在の Furigana Studio

Furigana Studio は下記の技術で開発しています。

  • アプリ基盤:Electron
  • ビルドツール:Vite
  • JavaScript フレームワーク:SolidJS
  • CSS フレームワーク:Tailwind CSS
  • サーバーサイド:PHP(Laravel)

まだまだ追加したい機能や不具合が残っているので、引き続き開発を進めていきます。今後とも Furigana Studio をよろしくお願いいたします。

furigana.studio

GPT-3.5に簡単な自然言語処理をさせてアプリに機能として組み込んだ話

趣味で Furigana Studio というふりがなに強い字幕動画ソフトをつくっています。かなりニッチ分野ですが少しずつ利用者が増えています。

今回はバックエンドに GPT-3.5 を使用して機能を開発した事例をまとめましたので共有させてください。

Furigana Studio では字幕ファイルを読み込むと自動で漢字にふりがなが付与されます。

「自動販売機」のような長い単語はまとめてふりがなが付きますが、下のように漢字一文字ずつふりがなを振りたいことがあります(漢字ごとの読みを強調したい場合など)

描画結果も変わります。分割後は漢字一文字ごとにふりがなが配置されるようになっています。

分割前 分割後

手動でも分割できるようになっていますが、この分割作業を自動で行いたいという要望がありました。ふりがなが、どの漢字に対応するかという情報が無いため大変そうだなと思っていましたが、GPT に分割させてみたらどうだろうと思い、やってみました。

環境は PHP (Laravel 9) です。 role:system で入出力のフォーマットを指定し、role:user で実際に分割したいテキストを指定しています。

$systemPrompt = <<<"EOT"
熟語:読み のリストを与えるので、読みを漢字ごとに区切って変換してください。以下の条件を厳密に守ってください。

## 条件
- 読みにあるひらがな以外は使用しないでください。
- 以下のフォーマット通りに出力してください。
- 出力結果以外は出力しないでください。

以下はサンプルです。
入力例:
四字熟語:よじじゅくご,情報発信局:じょうほうはっしんきょく

出力例:
四字熟語:よ|じ|じゅく|ご,情報発信局:じょう|ほう|はっ|しん|きょく
EOT;

$json = Http::withHeaders([
  'Authorization' => 'Bearer APIキー',
  'Content-Type' => 'application/json'
])
  ->accept('application/json')
  ->post('https://api.openai.com/v1/chat/completions', [
    "model" => "gpt-3.5-turbo",
    "messages" => [
      ["role" => "system", "content" => $systemPrompt],
      ["role" => "user", "content" => $query],
    ],
    "temperature" => 0.7
  ])
  ->json();

事前に入力の検証と、出力結果も下記の辺りで検証を行っています。 ・ふりがなの分割数が漢字の文字数と一致するか ・分割したふりがなを結合した時に元のふりがなと完全に一致するか

精度は、件数が多すぎなければかなり高いです。一気に100単語近く渡すと急激に精度が悪くなるので、ある程度分割して渡すようにしています。 コストも1リクエストあたり0.07円前後で許容範囲内でした(現状この機能は一部のユーザーのみに公開しています)

最近 Fine-tuning の API が公開されて気になっています(すでに実用レベルの精度なのもあってまだ試していません)

最新AIの無駄遣い感も若干ありますが、作業がだいぶ効率化できたようです。 プロンプトに対する助言などもあればご教示いただけましたら幸いです。

ふりがなに強い字幕動画ソフト「Furigana Studio」をリリースしました

タイトルの通り、ふりがなに強い字幕動画ソフトをつくってみました。学習教材の動画制作などを想定しています。

Furigana Studio

ブラウザで動く体験版も用意していますが、動画出力機能はアプリ版のみとなります(現在は Apple Silicon 搭載の macOS のみ対応しています🙇

ざっくり機能紹介

  • srt ファイルを読み込むと漢字のふりがなを自動生成
  • 漢字以外にも、英単語など好きな単語にルビを振れる
  • テキストデザイン機能
  • 書式を保存して使い回すことができ、作業が効率化できる
  • 透明背景の字幕付き動画(.mov)を出力する

もう少し詳細な機能説明スライド

フィードバックを大募集!

現在は試作品ということで、フィードバックを大募集しています。

Furigana Studio お問合せ・ご要望送信フォーム

なぜつくったか

学習系の動画制作をしていて字幕の「ルビ振り」作業に苦しんでいる人が身近にいました。それを見て自動化できそうだなぁと思ったことと、ちょうど Web 技術への興味が再燃していたため、つくってみよう、となりました。

工夫した点① なるべく作業を効率化できること

実際に字幕を作る様子を見ていて、けっこうつらい作業だなと感じたので、できる限り作業が効率化できるように意識しています。

複数選択に対応

テキストや単語は複数選択して、まとめて書式を編集することができます。

書式を保存して使い回せる

設定したテキストデザインや、書式を保存して他の場所で再利用することが出来ます。書式の保存では、配置設定も保存されます。

また、保存した書式をエクスポートして他の人に共有することも出来ます。

書式設定に「詳細度」をつくる

字幕を編集していく上で、「このテキストだけは、位置を他の字幕と変えておきたい」「この単語だけは、特別なデザインで固定しておきたい」「それ以外の全体のデザインを一括で変更したい」というシチュエーションがあります。

このために、書式設定に詳細度を作り、詳細度順に書式を「上書き」していく方式を取ることにしました。

対象を「全体」にして変更した内容は、プロジェクト全体のデフォルト設定として適用される

対象を「選択したテキスト」にして変更した内容は、選択中のテキストだけに上書きされる

個別に単語を選択した状態で変更した内容は、選択中の単語だけに上書きされる

この仕組みによって、「この単語だけはこのデザインで固定したい!」という要望と、「それ以外の全体のデザインをかんたんに変更したい!」という要望を両立しています。

ただし、編集の「モード」を設けることによって、若干操作が複雑になったかなという印象はあります。

工夫した点② デザインの制約をなるべく少なくすること

テキストスタイル

なるべくデザイン面での制約をつけたくありませんでした。現状は、テキストに対して CSS の TextShadow を設定可能にしています。これは設定次第で様々な効果をつけられて自由度はかなり高いと思います。

とはいえ、TextShadow だけでは実現が大変な効果や、よく使う効果については少し抽象化した機能を提供していこうと考えています。現在はアウトラインはより簡単に設定できるようになっています。

現在はアウトライン機能を抽象化して提供しています

背景スタイル

字幕の背景機能も少しこだわっています。背景色だけではなく、CSSでいうところの border-color, border-width, border-radius, padding を設定できます。

また、top, right, bottom, left それぞれに別の設定をすることもできて、下記のようなタイトルは背景機能だけで作ることが出来ます。

理想はプレビュー上に Gizmo を表示して、直感的に編集できたら良いと思うのですが、ちょっと大変そうだったので一旦すべての項目は入力欄に数字を入力したり、スライダーで操作する感じになっています。

おわりに

ところどころ荒削りなところがありますし、もう少し作り込みたいところもあるのですが、より良いものにするにはいったん公開してなるべく多くの方に触れていただき、フィードバックをいただいたほうが良いと思いリリースに至りました。

便利そうだと思っていただけたらぜひ使ってみてください。そして、冒頭にも書きましたがフィードバックを大募集しています!

Furigana Studio お問合せ・ご要望送信フォーム

また、周りにこういうアプリを欲しがってそうな方がいらっしゃいましたら、ぜひ紹介していただけると幸いです。

ロードバイクを始めました

おひさしぶりです。まさかの1年2ヶ月ぶりの更新です。

ロードバイクを始めました。近況報告です。

f:id:setchi_q:20211207011542j:plain

きっかけ

  1. 在宅勤務になって運動不足なので何か始めたい
  2. 近くに TREK 直営店があった
  3. Émonda SLR 9 eTap を試乗して感動してしまう

今までの流れ

ビンディングシューズを初めて買って

f:id:setchi_q:20211207011210j:plainf:id:setchi_q:20211207011227j:plain

フィッティングをしてもらって

f:id:setchi_q:20211207011340j:plainf:id:setchi_q:20211207014559j:plain

モーションキャプチャを使って体の動きを測定しながら自分に合ったポジションを出してもらいました。

トレーニングをして

f:id:setchi_q:20211207013412j:plainf:id:setchi_q:20211207013934j:plain

学生時代の同級生が一緒に練習してくれたり色々教えてくれたり大変お世話になりました。感謝🙏

大会に初出場しました

f:id:setchi_q:20211207011545j:plainf:id:setchi_q:20211207011548j:plain

何がおもしろいと感じたか

行動範囲が広がるところ

ロードバイクはポジションが出るとびっくりするほど良く進みます。行動範囲が広がって、良い景色を見に行ったり、おいしいもの食べたり、写真を撮ったり。

自分の身体にとことん向き合うところ

下半期は友達の影響でレースを意識してトレーニングをしていました。 速く走ろうとするといろんな壁にぶつかります。

専門店でフィッティングを受けることでかなりの精度でポジションが出るものの、追い込んで走り始めると 1mm の差でどこかが痛くなったりします。自分の体と向き合いながらポジションをコツコツ調整した結果、痛みが出なくなったときは謎の喜びがありました。

また、長距離を走るときは補給の量やタイミングのことも考える必要があります。補給が足りず足が無くなったり、うまく補給できたおかげで長距離を踏み続けることができたり、面白い体験がたくさんできました。年末は 214km の過去最長ライドを達成しました。

機材

目的を極限まで追求したアイテムは見ていて面白いです。カリカリにチューニングされたソースコードを眺めてるときの感覚に似てます。

  • 快適さのために光造形3Dプリントされたサドル
  • 重量と空力性能を両立したホイールやフレーム
  • チェーンをなめらかに変速させるために一枚一枚歯の形状が異なるスプロケット
  • 回転体の外周を極限まで軽量化する一本22gのチューブ

ロードバイクを始めて変化したこと

距離感覚が変わった

20km は近い。

金銭感覚が変わった

¥55,000 のサドルに納得する。

食生活が変わった

体に悪いものを食べなくなる。

おわりに

ロードバイクは自分の性に合っていて良い趣味を見つけたなと思います。

生涯スポーツとして、怪我や事故に気をつけながら長く楽しみたいです。

身体にやさしい作業環境をもとめて②

デスクワークによる首こりと緊張型頭痛がひどくて試行錯誤してる人です。

setchi.hatenablog.com

結論:オカムラのクルーズはいいぞ

Cruise & Atlas (クルーズ&アトラス)|デスク・テーブル|株式会社オカムラ

かなり高額ですが、デスクワークの首こりや頭痛に悩んでいる人は、一度試してみてほしいです。オカムラのショールームか、WORKAHOLIC で試せます。

WORKAHOLIC オフィスファニチャーセレクトショップ

特徴

天板の高さ・角度を無段階調整できます。 天板の下にレバーがあります。右のレバーで高さ、左のレバーで傾きを調整できます。

f:id:setchi_q:20201111193940j:plain

後傾姿勢で作業するのにちょうどよい高さ・角度にできます。 後傾姿勢だと頭や上半身の重さを背もたれに完全に預けることができるので、筋肉はこりにくいです。(その代わり運動不足が加速します)

できれば専用チェアもセットで購入する

自分はこのデスクを購入する前にゲーミングチェアを買っていたためそのまま使っていますが、できればクルーズデスクには専用のチェア(Atlas)を使ったほうが良いです。

クルーズデスクはかなり低座の設計になっていて、普通のチェアだと肘掛けが天板と干渉します。専用チェアの Atlas はクルーズデスク専用の低座設計になっているのでちょうど良い高さです。

Cタイプはサブ天板の耐荷重に注意

オカムラのクルーズデスクには旧モデルのBタイプと最新モデルのCタイプがあります。自分が買ったのは旧モデルのBタイプです。

Cタイプはサブ天板(ディスプレイとかを乗せるところ)の耐荷重が10kgなのに対して、Bタイプは耐荷重が30kgでした。

自分の使っている43インチディスプレイは重さが17kgあります。正直つぶれることは無いと思いますが、Cタイプは耐荷重を結構オーバーしていて長期の使用には不安だったため、Bタイプを購入しました。Cタイプのほうが値段が安いので本当はCタイプが欲しかったです。

Bタイプは脛をぶつけやすい

Bタイプの天板の下はこんなふうになっています。結構低い位置に柱があって、使い始めの頃によく脛をぶつけました。今は慣れたのでぶつけなくなりました。やはり低座の専用チェア(Atlas)を使ったほうが良いのだと思います。

Cタイプはこの柱の位置がもう少し高いので、専用チェアと合わせたらもっと使いやすいと思います。

f:id:setchi_q:20201111200245j:plain

感想

Cタイプは専用チェアとセットで24万円程度、Bタイプはデスクのみで20万円以上します。かなり高額な買い物でしたが、楽な姿勢で作業ができるなら…と思い切って購入してみました。結果的にはデスクワークの悩みがほとんど解決されたのでとても満足しています。

同じような悩みを持っている方の参考になれば幸いです。

身体にやさしい作業環境をもとめて

はじめに

一日中座って作業するデスクワーカーのなかには、肩こり・首こり・頭痛に悩まされている人も多いのではないでしょうか。

そんな悩みを一気に解決してくれるのがこちら!

f:id:setchi_q:20190625134413j:plain

altwork.com

お値段はなんと7,650ドル約82万円!

これがあれば体の痛みから開放されるけど、さすがにこれをポンと買えるほどの度胸はなかったので、他のアイテムを使ってできるだけ近い環境を整えてみました。

今のところ、悩んでいた首コリや頭痛が減って快適に過ごせているので紹介したいと思います。※この記事はアフィリエイトリンクを含みます。

全体像

f:id:setchi_q:20200402152946j:plain

椅子

AKRacing の Premium シリーズを使っています。

リクライニングと座面チルト機能がついているので、思いっきり後ろ側に倒します。すると頭の重みの大部分を背もたれに支えてもらえるので、首の負担が激減して首こりを回避できます。

f:id:setchi_q:20200402155931j:plain

以前使っていた Nitro シリーズには座面チルト機能がついていませんでした。これがないと深くリクライニングしたときにお尻が痛くなってくるので、新しく Premium シリーズを買い直しました。

f:id:setchi_q:20200309152831j:plain
座面が傾くことでリクライニング時にお尻が痛くならない

モニターを置いてるPC机とは別に、新しくこちらの机を購入しました。

机の真ん中の部分が傾くようになっていて、キーボードをちょうどいい角度に配置できます。

f:id:setchi_q:20200402161914j:plain

さらに、机の下に柱がないので椅子を奥深くまで入れやすいです。椅子を奥まで入れると背もたれのふくらみにひじが置けるようになって、腕の重さをほとんどひじで支えられるので全く肩がこりません。優勝!

f:id:setchi_q:20200402162443j:plain
背もたれ部のふくらみにひじが置ける

ということで、椅子と机を買い足して理想の環境に近づけてみました。 今のところとても快適に作業できています。

いろんな筋肉を使わなくなるので、意識して定期的な運動はしたほうがよさそうです。

モニター

これより先は肩こり・首こり・頭痛にはあまり関係ありませんが、せっかくなので紹介します。

LG の38インチ ウルトラワイドモニターを使っています。大きい画面は正義です。画面が大きいと、少し文字を大きめにして離れて見るという目にやさしい使い方ができます。

USB Type-C 端子は PD にも対応しているので、 MacBook Pro と繋ぐときはケーブルが一本で済みます。配線がスッキリして良いです。キーボードはディスプレイのUSBハブ経由で繋いでいます。

f:id:setchi_q:20200402165821j:plain

MacBook Pro 立て

MacBook Pro 立てです。机に平らに置くよりは放熱効率が良くなるうえに、机のスペースを有効活用できます。

f:id:setchi_q:20200402165840j:plain

ノートPCを縦にすることには多少抵抗あったんですが、思ったよりしっかりしてて大地震でも来ない限り倒れなさそうです。

キーボード

無線は使わない予定なので、あえて HHKB Professional Classic を使っています。必要最小限のスペックで、HYBRID のように電池を入れる出っ張りがなくて見た目もスッキリしていて気に入っています。インタフェースが USB C になって使いやすいです。

ただし、音がけっこう響くので、オンラインミーティング中に文字を打つときはミュートにしたほうが良さそうです。これから購入される方で、予算に余裕があれば静音タイプの Type-S を検討したほうが良いかもしれません。

ダイオウグソクムシ

f:id:setchi_q:20200402165948j:plain

水族館で買いました。 作業場の癒やし担当です。

【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

#TokyoDemoFest 2018 の GLSL Graphics Compo で2位入賞しました

はじめに

2018/12/01〜12/02 に開催された Tokyo Demo Fest 2018 というデモパーティに初参加してきました。Tokyo Demo Fest をご存知ではない方のために、説明を公式サイトから引用します。

Tokyo Demo Fest は日本で唯一のデモパーティです。 デモパーティは、コンピュータを用いたプログラミングとアートに 興味のある人々が日本中、世界中から一堂に会し、 デモ作品のコンペティション(コンポ)やセミナーなどを行います。 また、イベント開催中は集まった様々な人たちとの交流が深められます。


私は GLSL Graphics Compo という GLSL のフラグメントシェーダのみで映像を作って競う部門に作品をエントリーし、2位に選んでいただきました。ありがとうございます!

f:id:setchi_q:20181214234241j:plain

作品は下記からご覧になれます。

Pouet
GitHub
Shadertoy

www.youtube.com

f:id:setchi_q:20181215133023p:plainf:id:setchi_q:20181215133044p:plainf:id:setchi_q:20181215133107p:plainf:id:setchi_q:20181215133053p:plainf:id:setchi_q:20181215133051p:plain

解説

「生命」をコンセプトとして、一つのキューブから増殖して複雑に変形してから、元の形に戻って消えていくという作品になっています。キューブから複雑な形状を作るところには、フラクタルの描画などに使われる IFS という考え方を使いました。

vec2 ifs(vec3 p) {
    float d1 = 999., d2 = 999.;
    float range = 0.8, radius = 0.5 * (1. + zoom);

    const float maxIter = 8.;
    for (int i = int(maxIter); i > 0; i--) {
        if (i <= iter) {
            break;
        }

        float ratio = float(i) / maxIter;
        float bx = box(p, radius * ratio);
        d1 = mix(d1, min(d1, bx), float(i > iter + 1));
        d2 = min(d2, bx);

        ratio *= ratio;

        p.xz = abs(p.xz) - range * ratio * 0.7;
        p.xz *= rot1;
        p.yz *= rot3;
        p.yx *= rot2;

        p.yz = abs(p.yz) - range * ratio * 0.7;
        p.xz *= rot1;
        p.yz *= rot4;
        p.yx *= rot2;
    }

    return vec2(d1, d2);
}

キューブの増減は IFS 反復数の変化

f:id:setchi_q:20181215134717g:plain
序盤・終盤のキューブがぽこぽこと増減していく動きは、距離関数の中で IFS の反復数が一つ違う形状を同時に求めておき、それを線形補間することで実現しています。

序盤は増殖する方向に、終盤は縮小する方向に動くため、一定の時間を超えたら補間の係数を反転させて逆方向に補間しています。

float map(vec3 p) {
    vec2 d = ifs(p);
    return mix(d.y, d.x, mix(a, 1. - a, step(time0, 5.5)));
}

今回、グラフのスケッチには GLSL Grapher というサイトを使わせていただきました。GLSL コードを使ってそのままグラフが書けるので捗りました。

f:id:setchi_q:20181215012453p:plain
IFS 反復数の遷移を制御する式(整理したらもっと簡単になりそう)

軽量に法線を求める

レイマーチングで素直に法線を求めようとすると map 関数を6回評価することになりますが、iq 氏の記事に4回の評価で法線を求める方法が紹介されています。これで map 関数2回分の計算を省くことができます。

vec3 normal(vec3 pos, float eps) {
    vec2 e = vec2(1.0, -1.0) * 0.5773 * eps;

    return normalize(e.xyy * map(pos + e.xyy) +
                     e.yyx * map(pos + e.yyx) +
                     e.yxy * map(pos + e.yxy) +
                     e.xxx * map(pos + e.xxx));
}

また、この記事を書いてる途中で見つけたので今回の作品には使っていませんが、下記のようにするとコンパイラが map 関数を4回インライン展開するのを防ぎ、コンパイル時間の節約になるようです。(WebGL ではシェーダコンパイルに時間がかかりすぎるとブラウザがクラッシュすることがある)

vec3 normal(vec3 pos, float eps) {
    vec3 n = vec3(0.0);

    // 0 に評価される定数以外の低コストな式
    #define ZERO (min(iFrame, 0))

    // for の展開を防ぎコンパイル時間短縮 & ブラウザクラッシュ防止
    for (int i = ZERO; i < 4; i++) {
        vec3 e = 0.5773 * (2.0 * vec3((((i + 3) >> 1) & 1), ((i >> 1) & 1), (i & 1)) - 1.0);
        n += e * map(pos + eps * e);
    }

    return normalize(n);
}

自分自身はまだコンパイル時間の長さに悩まされたことはありませんが、 GLSL Compo 1位の kaneta 氏ブラウザクラッシュと戦っていたので、コンパイル時間短縮のテクニックとして覚えておくと良さそうです。

追記: for の初期値に定数以外の値を入れるのは WebGL 2.0 からでないと使えないため、 GLSL Sandbox ではコンパイル時間削減のテクニックは使えませんでした。(2018/12/17 時点)

エッジ検出

f:id:setchi_q:20181215172821p:plain

vec3 nor = normal(pos, 0.008);
float edge = smoothstep(0., 0.01, length(nor - normal(pos, 0.015)));

作品中で多用しているオブジェクトのエッジを強調する表現です。異なる epsilon で求めた法線の差分を見ることでエッジかどうかを判定しています。オブジェクトにヒットしたら大体一回は法線を求めると思うので、片方はその値を流用すれば少しお得です。

カメラワーク

序盤と終盤は狙ったところへ Hermite 補間でつないでいます。そこは良いのですが、中盤の視点がパッパッと切り替わっているところは、なんと擬似乱数によるオフセットです。乱数です。。次の TDF までにはカメラワークの技術を学んで自分の意思でかっこいい動きを作りたいです。

開発環境

VSCode 上で GLSL 環境を探していたときに、ちょうど gam0022 先生が GLSL Sandbox 互換の VSCode 拡張を公開していたのでありがたく使わせていただきました!

途中で Eclipse の GLSL 開発環境である Synthclipse というのを見つけて少し気になっていたのですが、今回は余裕がなくて試すことができませんでした。
synthclipse.sourceforge.net

GLSL Sandbox でコンパイルエラー

VSCode や Shadertoy でのびのび GLSL を書いて、いざ TDF 本番環境である GLSL Sandbox で動かそうとしたらそもそもコンパイルが通らず冷や汗をかく経験をしました。

GLSL Sandbox は 2018/12/17 現在 WebGL 2.0 に対応していないので、違う環境で開発している場合はこまめに GLSL Sandbox と同じ環境で動作確認した方が良いです。

例えば身近な abs 関数一つとっても、genType abs(genType x) は使えるけど、genIType abs(genIType x) は使えないといった差があります。

// Shadertoy ではどちらも OK
int abs1 = int(abs(4.0));
int abs2 = abs(int(4.0)); // こちらは GLSL Sandbox でコンパイルエラー

おわりに

Tokyo Demo Fest 2018 (2日目)の様子を Twitch からタイムシフトで見ることができます。特にコンペ中のテンションがすごくて、みなさん「おおおおお!」「Foooooo!!!」とか言いながら作品をみています。
www.twitch.tv

大スクリーンに上映された自分の作品に対して生の反応を得るという貴重な経験ができるので、興味を持った方はぜひ Tokyo Demo Fest に作品をエントリーしてみてください!

Tokyo Demo Fest はコンペ以外にも DJ/VJ, セミナー, シェーダーライブコーディングバトルなど様々な催しが行われており、とても楽しいイベントでした。来年も何か作品を出そうと考えていますが、仮に作品が出せなくても是非また参加したいと思います。

GLSL Tech Night 2018 #GLSLTech で登壇しました

2018/09/13 に開催された GLSL Tech Night 2018 で発表してきました。
connpass.com

私は「ビルトイン関数の使い方いろいろ!シェーダアートの表現力を高める小技集」というタイトルで、Unity-ShaderSketchesShadertoy での作品作りに利用したテクニックを雑多に紹介しました。

大雑把な内容

  • 基本形(2D)
  • モーフィング
  • アンチエイリアシング
  • イージング
  • モーションブラー
  • 空間の折畳み

f:id:setchi_q:20180922123853p:plain
f:id:setchi_q:20180922123918p:plain
f:id:setchi_q:20180922124420p:plain
f:id:setchi_q:20180922124204p:plain

シェーダでの 2D パターン作りについては、こちらの資料も参考にしてみてください。

他の登壇者の発表

Webフロントエンドで使えるシェーダーによるノイズ表現

GitHub - p5aholic/glsl-tech-night-2018: GLSL Tech Nightのデモプログラム

「楽しい!Unityシェーダーお絵描き入門!」という講演をしました

4/10 にサポーターズCoLab勉強会で「楽しい!Unityシェーダーお絵描き入門!」という講演をしました!内容はフラグメントシェーダで2Dの絵を描くための入門的な考え方を紹介するというものです。

当初の定員20名に対して150名近い申込みをいただきました。ありがとうございます。
supporterzcolab.com

スライドはGifアニメが多かったのと(Gifアニメを動く状態で公開できるスライド共有サイトが少ないことに驚きました)、スピーカー ノートで内容を追ってもらえれば資料として分かりやすいと思ったので Google スライドでそのまま公開させていただきました。

入門向けの内容になりますが、興味があればぜひご覧ください。

講演資料はいつもお世話になっている テラシュールブログ 様や、 3D人 様でも紹介してくださいました!ありがとうございます。
tsubakit1.hateblo.jp
3dnchu.com

シェーダーでお絵描きできると表現の幅が広がって面白いと思うので、興味が湧いた方は是非トライしてみてください。

3Dの絵や音楽も作れます

今回の講演では説明しませんでしたが、レイマーチングという手法を使うとシェーダーだけで3Dな絵も作れます。レイマーチングの考え方についてはこちらのスライドが分かりやすかったです。

www.slideshare.net

また、シェーダーで音楽を生成するというヤバい考え方もあります。それについてはこちらの資料がとても分かりやすく参考になりました。

さいごに

シェーダー沼、広く深くて楽しそうなので積極的に足を踏み入れていきたいです😇