musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

うえのさんインタビュー

mL民のインタビュー企画第2弾、2人目のインタビューです。
(インタビュー企画通算で8人目です!)
※mL民:作曲アプリmusicLineユーザーのこと
(「」タグで他も見てね☆)

今回はmL民のお母さん的存在のうえのさんを紹介します。いつもmL民の皆さんのことやアプリのことを考えて頂きありがとうございます。

DMでのやり取りをインタビュー形式にまとめました。

  


スー

うえのさん、よろしくお願いします。
それではまずユーザーネームの由来を教えてください。


うえのさん

本名ですが、ひらがなにする事により親しみやすく覚えてもらいやすいと思いました。


スー

本名そのままだったんですね!
確かにひらがなの方が親しみやすいですね~😊

あとずぅぅっと気になっていたんですが、 うえのさんのアイコンに500円玉を選んだ理由はありますか?


うえのさん

お金は大事なので丸いお金(アイコンのサイズ)で1番大きいのを選びました。


スー

お金が大事!!!!!


(。。えーと、みんな親しみ感じられてる?)


アゴォー

お金は だ・い・じ ☆
現実主義的な考え方、僕は好きだな~


スー

確かにその通りですが。。
はっきりと言いますね😅

今回はそんなうえのさんの魅力を掘り下げていきます✨
それでは、ひとこと自己紹介をお願いします。


うえのさん

音楽や絵、ドラマや映画などの作品を鑑賞する事が好きです。

最近は編集に興味をもち、自身の料理動画の編集に力を入れています。


スー

音楽以外にも色々な芸術を鑑賞することが好きなんですね♪
現状に満足せず新しいことに挑戦する姿もかっこいいと思います🙂

うえのさんの心に残っている作品をなにか一つ教えて頂いてもよろしいですか?


うえのさん

映画「タイタニック」です。船上の演奏家たちの最後の誇りが胸をうちます。


スー

名作ですよね!!
タイタニックは映画としても音楽としても素晴らしいので、うえのさんらしい回答ですね😊
また観たくなってきました~♪


(なんだか親しみを感じてきたぞ☆)

あとYouTubeも見させて頂きました!
料理するうえのさんの適当さが面白くて爆笑してしまいました😁
でもちゃんと合理的で節約料理の勉強にもなるし、料理の楽しさも伝わってきます✨

ダラーン

いや~これは絶賛したい♪
YouTubeでも料理番組でも


そんなに上手くいきますかぁ??

って突っ込みたくなるんですが、うえのさんの動画は普通に上手くいかないです😲(笑)
なので上手くいかないもどかしさを一緒に体験して、その時の対処法を学べるのがめっちゃ良いです😆
あと食材揃ってない時や省スペースでどう調理するのか、無駄にしない節約術がとても参考になります!!


スー

なるほど~
そんな目で料理番組みてたんだね☆

ちなみに、うえのさんおすすめの動画はなんですか?


うえのさん

1番再生数が多い「お好み焼き」ですかね。


スー

こちらがその動画です~

料理する方もしない方も是非観て下さい♪
お好み焼き食べたくなってきた😋


続いて、古くからアプリを使用して頂いてるうえのさんですが、musicLineを使っている理由はなんですか?


うえのさん

音楽を継続するに越したことはないのですが、私のように一旦リタイアして音楽を再開する人もいるので、20年も経つと初心者同様で、その中でも最も使いやすいアプリでした。


スー

musicLineを選んで頂いてありがとうございます~

アゴォー

20年のブランク!?

もしかして想像してるよりも上の世代なのか!!


スー

そこ!?😲
そこはどっちでもいいでしょ~
世代問わずコミュニケーションがとれるのが音楽の良さですね!!

でもアプリ作曲を受け入れて頂き嬉しいです😆
作曲はPCですることが当たり前だったので、上の世代にはあまり受け入れられてないんですよね😅
うえのさんはコメント機能を追加した当初から、よくコミュニティを利用していただいてます。

実はコメントした曲数が一番多いのが、うえのさんです✨

そこで質問です。
コメントで心がけてることはありますか?


うえのさん

1番多いのに驚愕ですが、心がけていることは「初投稿」のタイトルやタグがあると、慣れない場所に踏み込む勇気とゆうのが大変であることが分かるので、できるだけ声をかけたりいいねを押して2回目も待っていますよと伝えたい想いで聴いています。


スー

やさしい

すごくありがたいです♪
確かに初めてコミュニティに投稿するときって勇気がいりそうですよね😭
コメントをもらえたら嬉しいし、二回目も作ろうって思えますね✨
これからもよろしくお願いします!

今後の予定や目標を教えていただけますか?


うえのさん

金と権力が欲しいです。


スー

!!!


(親しみが遠のいていくよぉ。。)


アゴォー

金と権力☆
うん、良い響きだ~


うえのさん

生活の地盤があってこそ夢を見れるのであって、その地盤が整わない環境にいる人の助けに少しでもなりたいです。

そのための金と権力です。


スー

なるほど。
金と権力が欲しいってだけ聞くとかなりダークな感じですが、夢を見れるように手助けしたいってことなんですね🤔

そう思うきっかけになったことはありますか?


うえのさん

家族が病気になってお金が必要になったことがきっかけです。金と権力は悪用しなければ大きな力となります。


スー

そうだったんですね!!
ご苦労されてきたようですね。。


お子さんも育てられてとても尊敬します🙂
世の母親は強いことがよくわかりました!
なんだかうえのさんという人物が少しわかったような気がします。

それでは最後にみなさんに聴いてもらいたい自分のオリジナル曲を教えてください


うえのさん

「フリフリラ」です。 自身の子ども達に書いた前向きな曲で、料理動画のBGMに使っています。


スー

うえのさん「フリフリラ」
https://3musicline.com/community/16693 (アプリリンク)

素敵な曲ですね♪
タイトル「フリフリラ」にはどういう意味が込められてますか?


うえのさん

「フリフリラ」は自身の子ども達に書いた曲ですが全て白鍵で作っています。誰もが親しみ踊れる曲であることを意識しました。


スー

白鍵だけなんですね☆
みなさんもぜひ聞いてください♪

僕もピアノで弾いてみようかな~


というわけで今回はインタビュー企画第2弾、2人目のうえのさんへのインタビューでした。
お忙しい中ありがとうございました!

次回は現在ボカロPとして活躍されている、るりあさんを紹介しますのでお楽しみに☆


インタビュー インタビュー




白夜さんインタビュー

mL民のインタビュー企画第2弾1回目のインタビューです。
※mL民:作曲アプリmusicLineユーザーのこと
第1弾を含めると今回でインタビュー7人目になります✨
(「」タグで他も見てね☆)

インタビュー企画第2弾は僕スー(運営)が気になる3名の方にオファーさせていただきました!

今回は白夜さんを紹介します。
DMでのやり取りをインタビュー形式にまとめました。

  


スー

白夜さん、よろしくお願いします。
それではまずユーザーネームの由来を教えてください。


白夜

本名から2文字取ってこれにしました。


スー

なんと!本名からなんですね☆

。。。もしかして!

いや、気になりますが予想するのはやめておきます😊

では次に、ひとこと自己紹介をお願いします。


白夜

白夜です。musiclineはかなり昔から使わせていただいています。
最近はあまり作曲できていないので、私のことを知らない方もいらっしゃるかと思いますが、このインタビューを見て少しでも興味を持っていただけたら嬉しいです。


スー

本当に長く使って頂きありがとうございます!
確かに最近musicLineを使い始めた方は知らないかもしれないですね😭

でも殿堂入りを果たしたり、第1回作曲コンテスト「RPGのBGM」では1位に輝き、昔から魅了的な作品を多く残して頂いているので、オファーしました😆
今回はそんな白夜さんにインタビューできて嬉しいです♪

白夜さんと言えば歯車のアイコンが特徴的ですが、

歯車を選んだ理由はありますか?


白夜

なんかかっこよかったからです!
ただずっと使う気はなくて、いいアイコンが見つかるまでのつなぎとして使っていたのですが、歯車のイメージが定着してしまっていたので結局そのままになっています。


スー

シンプルーーーな答えですね!


でも歯車かっこいいのはわかります~
なんか感覚が似てるかもしれないですね😄

聞くと最近かなり多忙だということですが、どういう仕事をされていますか?


白夜

あまり詳細は言えませんが、企画開発系の仕事をしています。音楽とは完全に無縁の職です。


スー

企画開発ってかなり興味深いところですが。。
色々と言っちゃいけないことが多そうですね~

でも作曲できることって発想力とか論理的思考力が高いと思います!
なので音楽が企画開発の仕事に繋がったんじゃないかな~
そっか!musicLineで作曲してたから、企画や開発の能力が鍛えられたのでは😋

なんてね~
(都合よく解釈しちゃお☆)



それでは、次の質問です。
好きな楽器はなんですか?


白夜

私の曲を聞いたことがある方はだいたい予想できると思いますが、アコースティックピアノが好きです。


スー

確かに、ピアノのイメージありますね〜


白夜

軽く作曲したい際に、楽器1つで完結させられるアコースティックピアノにはつい頼りがちになってしまいます。
ちなみにピアノは弾いたことがないので、こんなん人間には弾けないよって曲になっていても見逃してほしいです...


スー

ピアノ弾いたことないんですか!それでも表現力が高くてびっくりです😲
打ち込みは楽器の経験がなくても良いので、そういう方にもmusicLineを使って頂いて嬉しいです。

こんなん人間には弾けないよって曲、僕は好きです☆
そういう曲を表現できるのも打ち込みの魅力ですからね~♪

音楽知識があまりないということですが、作曲は感覚で音を追加していくような感じですか?


白夜

そうですね。理論はあまり勉強していないので、感覚に頼っています。
ただ、感覚だけで作っているとなかなか引き出しが増えないと感じるので、いずれ理論についてもちゃんと勉強したいですね。


スー

確かに音楽理論があると、引き出しが増えそうですね~



うんうん

(お、いたんだダラーン)

ダラーン

musicLineとしては自然と音楽理論が身に付くような設計にしたいと思ってます。音楽理論って程のものでなくとも、次はどうすれば良い曲に磨いていけるのかがわかるような仕組みを作りたいなあ🤔
知識なしでもセンスで突っ走れば良いけど、大半が作曲できたって実感が沸かずに辞めちゃうんだよね。。

それは勿体ない!!
少しの知識でも、次はこうしてみようかなって思えることで作曲が楽しくなると思うな~


スー

うんそうだね~
ごめんなさい突然musicLineの想いを語っちゃった😄
ありがとう、ダラーン

気を取り直して、
感覚に頼っているということですが、白夜さんは作曲する際に心がけていることはありますか?


白夜

シンプルかつ綺麗な音が鳴るように心がけていますが、如何せん音楽的な知識があまりないので、いつも頭を捻りながら作曲しています。


スー

シンプルーーーな音!

白夜さんの曲は繊細でエモいメロディな曲が多いように感じていましたが、シンプルと綺麗を心がけてるんですね♪
知識よりも音を置いては消してを繰り返して紡ぎ出される感じですかね~🙄

でも音楽知識がないと、思うように作曲できなくて嫌になってしまうような気がします。
作曲始めた頃はどういう思いで作曲してましたか?


白夜

私の場合は、むしろ作曲始めたての方がのめりこめていましたね。なんとなく音を置いて、思ったとおりの音が鳴らせた時が楽しくて、ひたすら曲を作っていました。
むしろある程度自分のスタイルが固まってきて、曲調がワンパターン化してしまう時期の方がモチベーションを保つのが難しい気がします。そういった点で、日々新しい曲調にチャレンジしているmlの方々はとても尊敬しています。
私もそう在れたらいいなと常々思っていますが、そもそもここまで作曲を続けてこれたのは、始めるにあたってmusiclineという使いやすいツールに出会えた事が大きいです。素敵なアプリをありがとうございます🙇‍♂️


スー




musicLineを褒めて頂いてありがとうございます!
思った通りの音が鳴らせた時って楽しいですよね😁

確かに、作曲初めのわからない時期もそうですが、新鮮さを味わえなくなったマンネリ化の時期もハードルがありますね。
何事も続けることは難しいですが、同じ時期に作曲してるmL民がいるって感じれれば、もうちょっと続けるかって気持ちになるかもしれませんね~
白夜さんをはじめ、日々チャレンジングなmL民の方々に感謝です😊

今後の予定や目標を教えていただけますか?


白夜

最近は短いフレーズしか作らなくなってしまっているので、ちゃんとしたものを1曲作るのが目標でしょうか。


スー

今は短い曲も流行っているようですが、ぜひまたフルの曲も期待しています!


白夜

あとiOSに移行してしまったので、iOS版のmusiclineが出ることがあればまたそちらにも顔を出したいと思っています。


ダラーン

ありがとうございます!
iOS版を作る励みになります😆

スー

また白夜さんの曲聴きたいな~
インタビュー時は告知してませんでしたが、iOS版は来年(2025年)夏リリースを目標にしています☆
お仕事も忙しいと思いますが、iOS版を作って待ってます♪

急がなきゃー



それでは最後にみなさんに聴いてもらいたい自分のオリジナル曲名を教えてください!


白夜

night skyと夕立ですかね。
どちらも殿堂入りはしていないのですが、個人的に気に入っている2曲です。


スー

白夜「Night sky」
https://3musicline.com/community/65942 (アプリリンク)

白夜「夕立」
https://3musicline.com/community/34130 (アプリリンク)

殿堂入りしてなかったんですね!
どちらも聞かせていただきました😊
みなさんもぜひ聞いてください♪

というわけで今回はインタビュー企画第2弾1回目の白夜さんのインタビューでした。
お忙しい中ありがとうございました♪
企画開発のお仕事も大変そうですが、無理せずに頑張ってください!

次回はmL民のお母さん的存在、うえのさんを紹介しますのでお楽しみに☆


インタビュー第一弾へ インタビュー




【iOS】作曲画面の進捗報告 6

musicLine(iOS)についての進捗。
フレーズツールの追加実装と通知機構の見直し、描画速度の改善について報告します。

フレーズツールと変更通知の動作


実装状況
カテゴリタイトル補足
View
Composition
UI配置
ガイド表示
音符描画の高速化音符が多くても描画できる速さへ最適化
音符の編集状態選択や変更した時に枠線の色変更
Composition
Common
作曲共通
通知機構Modelに変更があった時に、Viewへ通知して再描画。変更状況を監視してキャッシュ
PhraseTool
フレーズツール
フレーズの編集
データ構造トラックのフレーズを取得・追加、削除、状態 通知機構の実装を見直す。現状は一部描画がおかしい
フレーズの結合連続する2フレーズの接する小節線をタップで結合(iOS独自)
フレーズの分割フレーズをタップで分割(iOS独自)
MIDI
Common
MIDI共通
データ構造MIDIフォーマットへ出力できる構造
コンバーターCompositionのデータ構造へ変換・逆変換


全進捗マップを表示

カテゴリタイトル補足
View
Composition
UI配置
ガイド表示
ピアノ
スクロールエリア
小節番号
小節線
分割線拡大率によって間隔を変化
フレーズフレーズがないところはフレーズ作成ボタンを表示
ツールボタン 選択中のツールはツール色にハイライトする
メロディーライン ペンツールでの音符作成時の入力線
メロディー音符 音符の先頭にチョボをつける
リズム音符 ベース音符のみ下に表示
サンプルデータ 手入力したサンプルデータの音符を配置
連符連符は数字で表示、拡大率によって省略
ツールガイド(ペン)長さ編集しているリズム音符、伸ばした時の削る範囲音符移動時、移動先の音階をわかりやすくする
ツールガイド(指)移動中の音符影、矩形選択の枠
ツールガイド(消しゴム)削除範囲、和音のみの削除範囲
ツールガイド(フレーズ)フレーズ移動・伸縮のバー、選択範囲テキスト(iOS独自)
フレーズボタンフレーズボタンとサブボタンを配置、ツールに応じてアイコンを変更、画面拡大率に応じて縮小
フレーズタブツールに応じてアイコンを変更、拡大率に応じて縮小、スクロール状態に応じて移動
音符描画の高速化音符が多くても描画できる速さへ最適化
音符の編集状態選択や変更した時に枠線の色変更
音符音階メロディ音符に音階表示
Community
UI配置
Dialog
ダイアログ
フレーズフレーズの作成・設定・挿入、フレーズの長さやリピート回数の設定
Composition
Common
作曲共通
データ構造座標とサイズ、状態を保持する。レンダラーやコライダー、通知等のロジック
コンバーターDomainのデータ構造へ変換・逆変換
通知機構Modelに変更があった時に、Viewへ通知して再描画。変更状況を監視してキャッシュ
FingerTool
指ツール
音符の編集
データ構造フレーズの音符を取得、基点と移動ベクトルを保持して移動する
音符の移動和音や連符は上下のみに制御
音符の影移動中に操作できているかわかりやすいように(iOS独自)
内外判定音符の移動をフレーズ内に留める
衝突判定移動する音符が他の音符に重なったときの挙動
タップ選択タップした音符の子音符も選択・解除
矩形選択囲った音符を選択
音符の入れ替えリズム音符のスライドで重なった音符と入れ替える(iOS独自)
和音の削除和音を移動した時に重なる時に削除する
連符に切り替えリズム音符タップで切り替え
PenTool
ペンツール
音符の作成
データ構造音符を作成・分割・統合、フレーズへ音符を追加する
音符の作成タップで音符を作成。分割線に合わせて長さを決定
音符列の作成スワイプに沿って複数の音符を作成。音階変わる時に音符分割
有無判定タップやスワイプした縦ラインに既に音符があるか判定
音符の移動音符が既にある場合は、作成ではなく移動。一定距離進むと移動終了
音符の分割リズム音符をタップした時、音符を分割、メロディ線に沿ってY位置を動かす
音符の統合2つのリズム音符の間をタップした場合、音符を統合
音符の伸縮リズム音符を左右にスワイプすることで音符の長さを伸縮、始点で分割なし(仕様変更)
音符の消滅音符の伸縮をした時に長さが0になると削除する(iOS独自)
領域差演算音符伸縮で他の音符に重なる時は差演算して他の音符長さを削る
サンプリング補間素早くスワイプしても音符が移動できるようにサンプリング間を補間する
細かい音符分割線内の細かな音符を移動する挙動
EraserTool
消しゴムツール
音符の削除
データ構造和音と連符の認識、フレーズから音符を削除する
音符の削除タップで音符を削除
和音・連符の削除和音・連符をタップで和音・連符のみを削除(iOS独自)
音符種別判定タップした音符が和音・連符・ルート音符なのか判定する
音符列の削除スワイプで指定する削除範囲内の複数の音符を削除。
和音列の削除リズム音符をスワイプで複数の和音のみを削除。(iOS独自)
休符に切り替えリズム音符タップで休符に切り替える
PhraseTool
フレーズツール
フレーズの編集
データ構造トラックのフレーズを取得・追加、削除、状態 通知機構の実装を見直す。現状は一部描画がおかしい
フレーズの作成・挿入ダイアログで長さとリピートを設定 リピートフレーズは未実装
フレーズの選択2点タップで範囲選択、範囲ガイドタップで選択解除(仕様変更)
フレーズの移動左右スワイプで移動、移動は選択フレーズとスワイプ中のフレーズを含む
フレーズの伸縮サブ編集領域をスワイプした時にフレーズ長さを伸縮、長さ0で削除(iOS独自)
フレーズの貼り付け選択フレーズをコピー・ペースト、サブボタンかフレーズタブタップで挿入、選択解除
周囲有無判定作成する小節の近くにフレーズがあるかの判定、設定できる長さやリピート回数の制御
フレーズボタン・タブツールに応じてフレーズボタン、タブの処理を変更
フレーズの結合連続する2フレーズの接する小節線をタップで結合(iOS独自)
フレーズの分割フレーズをタップで分割(iOS独自)
StampTool
スタンプツール
モチーフの編集
Transform
画面移動
座標変換
画面を上下移動ピアノのスワイプにより
画面を左右移動 スクロールエリアのスワイプにより
画面を拡大・縮小ピンチアウト・インにより基点を画面中心に設定する
画面を拡大・縮小(軸指定)長押しからのドラッグ。ピアノでX軸方向、スクロールエリアでY軸方向
MIDI
Common
MIDI共通
データ構造MIDIフォーマットへ出力できる構造
コンバーターCompositionのデータ構造へ変換・逆変換
Commnity
Common
コミュニティ
共通
データ構造カテゴリ等の曲情報、いいねやお気に入り等のリスポンスを保持
Domain
データ構造
Json形式
Melody MelodyTrack, *MelodyPhrase, NoteContainer, NoteBlock, Note
Drum DrumTrack, *DrumPhrase, BeatContainer, Beat
通化 Original, Repeat, Syncの3種のPhraseをジェネリッククラスとプロトコルで抽象化
Service
サービスモデル
SongRederJsonファイルを読み込み、Domainのデータへ変換
SongWriterDomainのデータからJsonファイルへ書き出し
Common
Service
サービスモデル
MidiPlayerMidiファイルを再生する
リポジトリ保存データを管理


フレーズツールの実装

フレーズの結合

リズム編集領域でフレーズの境目をタップすることで2フレーズを1フレーズに結合します。

フレーズの結合

フレーズの結合はiOSからの新しい操作ですが、音符の統合も同じような操作(音符間の境目をタップ)なので、違和感はないと思います。


フレーズの分割

リズム編集領域でフレーズ内の小節線をタップすることで、その小節線から左右にフレーズを分割します。
なお、音符がフレーズ外にある時は切り取り、音符がフレーズ間を跨っている場合は長さを短くします。

フレーズの分割

こちらも音符の分割と同じような操作を意識しました。



通知機構の見直し

通知機構
音符の編集状態

通知で音符の編集状態が変化

前回はフレーズを変更しても描画に反映されない不具合があり、スクロールをして強制的に描画を要求してました。
これはモデルの通知機構に問題があり、モデルの変更を適切に監視できてないことが要因になっていました。モデルの監視について、ObservableObjectからEventBusの仕組みに変更することで解決しました。

通知機構の選定など内部的な話はこちらの記事で紹介しています。


描画速度の改善

描画速度の改善によるFPSの向上

リズム音符の描画方法を見直し、処理の最適化を行いました。このくらい音符を敷き詰めるとfps10程度になっていましたが、fps20程度に向上しています。



おわりに

フレーズツールの追加実装と通知機構の見直し、描画速度の改善を行いました。

フレーズの結合・分割はmusicLine(Android版)で要望で上がっている機能改善項目です。Androidは基盤が固まっているので修正がかなり大変ですが、iOS版はリメイクするついでに要望も取り入れてます。iOSリリース後に良い機能はAndroidにも反映していきたいと思います。

通知機能は不安定でしたが、かなり安定してきて一安心です。
描画速度が遅いと最後にボトルネックになるので今のうちからちゃんとして最適化しておかないといけないですね。

次は、MIDIのデータ構造へ変換するコンバーターモデルを実装していきます。musicLineの作曲データをMIDIのデータへ変換できると、MIDIプレイヤーで音を鳴らすことができるようになります。



記念動画デザインの観察(ランキング・急上昇・殿堂入り編)

musicLineの記念動画(ランキング・急上昇・殿堂入り)のデザインを紹介。
内部の実装もちょこっと紹介!の第二弾です。

ランキングの記念動画



はじめに

musicLineコミュニティでは曲(MIDI)の再生時に音の可視化映像が流れますが、ランキング等で上位に上がった曲はキラキラしたリッチな表現の映像になります。

ちなみに、前回はコンテストの記念動画を紹介しました。

今回はランキング・急上昇・殿堂入りの映像について観察し、内部実装(GLSLのコーディング)について軽く触れます。



デザインの観察

ランキングと急上昇の記念動画 1~8 位と殿堂入り含めて合計17種類あります。
各々のバリエーションを比較して、記念動画の要素を分解することでデザインを観察します。

バリエーションの比較

殿堂入りとランキング・急上昇の 1~4 位の記念動画を比較します。

殿堂入り
ランキング 急上昇

各々の記念動画を比較すると

  • 順位ごとにテーマカラーがある
  • モチーフの図形が異なる(丸、菱形、格子)
  • ランキング・殿堂入りはノイズ感がある
  • ランキングはチェック模様、殿堂入り奥行きのある背景
  • 3位以上はより華やなエフェクト
  • 殿堂入りは色合いにグラデーションが掛かる

等の違いがあります。


以下に紹介させて頂いた曲のアプリリンクを載せています。
気になった曲はmusicLineで聴いてください ♪

殿堂入り
ランキング 急上昇


要素の分解

この記念動画を分解すると、4つの要素(レイヤー)で構成されています。

名前 イメージ
エフェクト
情報
コンテンツ
背景

この内、エフェクトと背景を変えることで記念動画のバリエーションを出しています。

エフェクト(ランキング)



エフェクトの実装

今回はランキング記念動画を単純化したピカーンエフェクトの実装を紹介します。AndroidはOpenGLESが動作するため、実装はGLSLを使いました。

ピカーンエフェクト

なお、サンプルはWebでGLSLを実行できるエディタ** The Book of Shaders Editor **で表示して確認しました。


エフェクトの構成要素

ピカーンエフェクトは3種類のアルファアニメーションを用いて、カラーとテクスチャをブレンドすることで実現しています。

構成要素 イメージ
アルファ 1

パターン
アニメーション

アルファ 2

コースティクス
アニメーション

アルファ 3

リフレクション
アニメーション

カラー
テクスチャ


パターンアニメーション(アルファ 1)の作成


パターンアニメーションは座標変換アニメーションの2ステップで解説します。

1. 座標変換

菱形を描画しやすいように、
ST座標回転ST座標タイル座標
と座標を変換します。

ST座標

precision mediump float;
uniform vec2 u_resolution;

void main() {
    vec2 st = gl_FragCoord.xy / u_resolution.xy; // 正規化
    gl_FragColor = vec4(st, 0., 1.); 
}

左下原点(0, 0)でサイズ 1 の基準となるST座標空間へ変換します。
このサンプルでは座標を可視化するため、gl_FragColorの赤と緑の値にXとYの値を渡して色を表示しています。なので、値が大きくなる程色がつきます。例えば、座標(1, 1)はRGB(1, 1, 0)となり黄色になります。

回転ST座標(45度回転)

...
vec2 trans_st = (st - 0.5); // 原点を中心に移動    

float rot = radians(45.); 
mat2 rot_matrix = mat2(cos(rot), -sin(rot), sin(rot), cos(rot));
vec2 rot_st = trans_st * rot_matrix; // 45度回転
gl_FragColor = vec4( rot_st, 0., 1.);

菱形になるようにST座標空間を45度回転します。
回転は行列演算を用いますが、これは原点中心を起点として回転します。回転する前に原点を画面の中心に移動しておくことで、後の処理が計算しやすくなります。

タイル座標

vec2 scaled_st = rot_st * 6.;  // スケーリング
vec2 shift_st = scaled_st + .5; // 位置をずらす
vec2 tile = fract(scaled_st); // 小数点のみ(0 ~ 1の間を繰り返す)
gl_FragColor = vec4(tile, 0., 1.); return;

パターンアニメーションは中心の菱形から波紋状に周囲の菱形へ広がっていくアニメーションになります。そのため、同じ菱形が繰り返されるように、座標をタイル状に分割します。shift_stで中央に菱形が来るように位置をずらします。


2. アニメーション

アニメーションは全体と細部の2種類あります。
タイルアニメーションで全体の動き、スケールアニメーションで細部の動きを制御します。

タイルアニメーション(全体)

// 中央からタイルまでの距離
vec2 tile_pos = floor(shift_st);  // 整数へ(小数点切り捨て)
float dist = length(tile_pos);
      
// アニメーション進捗の調整
float progress = sin(u_time - dist); // -1 ~ 1 を周回
float easeIn_progress = progress * progress; // イージング(徐々に加速)
float adjust_progress = pow(easeIn_progress, 2.2) * 0.5; // 出現時間のバランスを調整
gl_FragColor = vec4(vec3(adjust_progress), 1.);

floorでタイルの座標を取得し、lengthで中心からの距離を算出しています。外側に行くほどタイルの色が明るくなります。

progressは中央からの距離と時間に応じてタイル毎のアニメーション進捗度を-1 ~ 1で算出します。ざっくり言うと0 ~ 1でアニメーションを行い、-1 ~ 0でアニメーションを休憩するイメージです。
progressのままでは単純なSin波なので、イージングでメリハリをつけたり出現時間のバランスを調整します。

ちなみに、イージングの変換数式はこちらが参考になりました。 easings.net

スケールアニメーション(細部)

// ルール画像(タイル端から中心までの距離 端 0 中心 0.5)
float animation_rule = min(min(tile.x, 1.0 - tile.x), min(tile.y, 1.0 - tile.y));

// ルール画像を元にアニメーション
float pattern_margin = 0.15;
float preview_progress = pow(sin(u_time), 2.) * .5;
float preview_value = smoothstep( preview_progress - 0.05, preview_progress, 0.95 * (animation_rule - pattern_margin));
gl_FragColor = vec4(vec3( preview_value), 1.); return;

アニメーションするために、ルール画像というものを作成します。ルール画像はアニメーション進捗度0 ~ 1を明度で示した画像です。真ん中から外へ広がるように明度を指定しています。

あとはsmoothstepを使って、ルール画像に基づき菱形が拡大縮小するような動きに制御します。

こちらはsmoothstep関数やShaderについての解説されています。
thebookofshaders.com



コースティクスアニメーション(アルファ 2)の作成

float h12(highp vec2 p) {
    return fract(sin(dot(p,vec2(32.52554,45.5634)))*12432.2355);
}
            
float n12(highp vec2 p)
{
    vec2 i = floor(p);
    vec2 f = fract(p);
    f *= f * (3.-2.*f);
    return mix(
        mix(h12(i+vec2(0.,0.)),h12(i+vec2(1.,0.)),f.x),
        mix(h12(i+vec2(0.,1.)),h12(i+vec2(1.,1.)),f.x),
        f.y
    );
}
            
float caustics(highp vec2 p, highp float t)
{
    highp vec3 k = vec3(p,t);
    highp float l;
    mat3 m = mat3(-2,-1,2,3,-2,1,1,2,2);
    float n = n12(p);
    k = k*m*.5;
    l = length(.5 - fract(k+n));
    k = k*m*.4;
    l = min(l, length(.5-fract(k+n)));
    k = k*m*.3;
    l = min(l, length(.5-fract(k+n)));
    return pow(l,7.)*25.;
}

...
float caust = caustics(st, u_time * 0.3);
gl_FragColor = vec4(vec3(caust), 1.); return;

コースティクスは水に乱反射して映し出された光を意味し、光のCG表現で使われたりします。
こちらは処理が複雑すぎて理解できなかったのですが、乱数に上手いこと規則性を持たせているようです。この辺りは数学の深い部分に入らないとわからなそうですね。。
とりあえず、caustics関数を使うことで表現できます。



リフレクションアニメーション(アルファ 3)の作成

float interval_time = 2.; // 光線が通る時間の間隔
float gap_pos = st.x * 2.0 + st.y; // 斜めにずらす
float speed = 8.0;
float line_width = 1.;
    
float dist_to_line = mod(gap_pos - u_time * speed, interval_time * speed);
float line = (1.0 - step(line_width, dist_to_line));
float decay_line = line - (line_width - dist_to_line) * line;
gl_FragColor = vec4(vec3(decay_line), 1.); 

dist_to_lineは原点から光線までの距離ですが、光線が通る時間の間隔が大きいほど座標系を大きくします。modは剰余演算(割り算の余り)なので、特定の区間を繰り返すことになります。decay_lineで光線が減衰するようにしています。



カラーの作成

vec3 shift_col(vec3 RGB, vec3 shift) {
    vec3 RESULT = vec3(RGB);
    highp float VSU = shift.z * shift.y * cos(shift.x * 3.14159265 / 180.0);
    highp float VSW = shift.z * shift.y * sin(shift.x * 3.14159265 / 180.0);
    
    RESULT.x = (.299 * shift.z + .701 * VSU + .168 * VSW) * RGB.x
        + (.587 * shift.z - .587 * VSU + .330 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU - .497 * VSW) * RGB.z;
    
    RESULT.y = (.299 * shift.z - .299 * VSU - .328 * VSW) * RGB.x
        + (.587 * shift.z + .413 * VSU + .035 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU + .292 * VSW) * RGB.z;
    
    RESULT.z = (.299 * shift.z - .3 * VSU + 1.25 * VSW) * RGB.x
        + (.587 * shift.z - .588 * VSU - 1.05 * VSW) * RGB.y
        + (.114 * shift.z + .886 * VSU - .203 * VSW) * RGB.z;
    
    return (RESULT);
}

...
float gap_pos0 = (st.x + st.y); // 斜めにずらす
vec3 shift = vec3(200.0 * (gap_pos0 + u_time), 1.0, 1.0);
vec3 rainbow = shift_col(vec3(0.5, 0.3, 0.2), shift);

shiftは(色相、彩度、明度)をshift_col関数に指定しますが、そのうちの色相のみ変更しています。色相はST座標と時間で決めており、時間が進むことで色が変化していきます。



テクスチャの作成

highp float random(highp vec2 st){
    return fract(sin(dot(st.xy ,vec2(12.9898,78.233))) * 43758.5453);
}

...
float sand_noise = mix(.7, 1., random(st));
gl_FragColor = vec4(vec3(sand_noise), 1.);

サンドノイズのテクスチャはrandom関数で簡単にを作れるので、金属感を出す時などにおすすめです。最小を白と最大を黒にすると、コントラストが効きすぎるのでmixで微調整しています。


全コードを表示

#ifdef GL_ES
precision mediump float;
#endif

uniform vec2 u_resolution;
uniform float u_time;

// 乱数を取得する。
highp float random(highp vec2 st){
    return fract(sin(dot(st.xy ,vec2(12.9898,78.233))) * 43758.5453);
}

// shiftにより、色をずらす。
vec3 shift_col(vec3 RGB, vec3 shift) {
    vec3 RESULT = vec3(RGB);
    highp float VSU = shift.z * shift.y * cos(shift.x * 3.14159265 / 180.0);
    highp float VSW = shift.z * shift.y * sin(shift.x * 3.14159265 / 180.0);
    
    RESULT.x = (.299 * shift.z + .701 * VSU + .168 * VSW) * RGB.x
        + (.587 * shift.z - .587 * VSU + .330 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU - .497 * VSW) * RGB.z;
    
    RESULT.y = (.299 * shift.z - .299 * VSU - .328 * VSW) * RGB.x
        + (.587 * shift.z + .413 * VSU + .035 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU + .292 * VSW) * RGB.z;
    
    RESULT.z = (.299 * shift.z - .3 * VSU + 1.25 * VSW) * RGB.x
        + (.587 * shift.z - .588 * VSU - 1.05 * VSW) * RGB.y
        + (.114 * shift.z + .886 * VSU - .203 * VSW) * RGB.z;
    
    return (RESULT);
}

float h12(highp vec2 p) {
    return fract(sin(dot(p,vec2(32.52554,45.5634)))*12432.2355);
}
            
float n12(highp vec2 p)
{
    vec2 i = floor(p);
    vec2 f = fract(p);
    f *= f * (3.-2.*f);
    return mix(
        mix(h12(i+vec2(0.,0.)),h12(i+vec2(1.,0.)),f.x),
        mix(h12(i+vec2(0.,1.)),h12(i+vec2(1.,1.)),f.x),
        f.y
    );
}
            
float caustics(highp vec2 p, highp float t)
{
    highp vec3 k = vec3(p,t);
    highp float l;
    mat3 m = mat3(-2,-1,2,3,-2,1,1,2,2);
    float n = n12(p);
    k = k*m*.5;
    l = length(.5 - fract(k+n));
    k = k*m*.4;
    l = min(l, length(.5-fract(k+n)));
    k = k*m*.3;
    l = min(l, length(.5-fract(k+n)));
    return pow(l,7.)*25.;
}

// タイル座標(回転あり)を取得する。
vec2 convertRotatedTileCoord(vec2 st, float degree, float repeat){
    vec2 trans_st = (st - 0.5); // 原点を中心に移動
    float rot = radians(degree); 
    mat2 rot_matrix = mat2(cos(rot), -sin(rot), sin(rot), cos(rot));
    vec2 rot_st = trans_st * rot_matrix; // 回転
    vec2 scaled_st = rot_st * repeat;  // スケーリング
    return scaled_st;
}

void main() {
    // ST座標
    vec2 st = gl_FragCoord.xy / u_resolution.xy;
    // gl_FragColor = vec4(st, 0., 1.); return;
    
    
    // 回転ST座標
    vec2 scaled_st = convertRotatedTileCoord(st, 45., 6.);
    vec2 shift_st = scaled_st + .5;
    // gl_FragColor = vec4(shift_st, 0., 1.); return;
    
    
    // タイル座標
    vec2 tile = fract(shift_st); // 小数点のみ(0 ~ 1の間を繰り返す)
    // gl_FragColor = vec4(tile, 0., 1.); return;
      

    // タイルアニメーション(全体)
    vec2 tile_pos = floor(shift_st);  // 整数へ(小数点切り捨て)
    float dist = length(tile_pos);
    // gl_FragColor = vec4(vec3(dist * .1), 1.); return; // 中央からタイルまでの距離
      
    // アニメーション進捗の調整
    float progress = sin(u_time - dist); // -1 ~ 1 を周回
    float easeIn_progress = progress * progress; // イージング(徐々に加速)
    float adjust_progress = pow(easeIn_progress, 2.2) * .5; // 出現時間のバランスを調整
    // gl_FragColor = vec4(vec3(adjust_progress), 1.); return; 

    
    // スケールアニメーション
    // ルール画像(タイル端から中心までの距離 端 0 中心 0.5)
    float animation_rule = min(min(tile.x, 1.0 - tile.x), min(tile.y, 1.0 - tile.y));
    // float preview_progress = pow(sin(u_time), 2.) * .5;
    // float preview_value = smoothstep( preview_progress - 0.05, preview_progress, 0.95 * (animation_rule - .15));
    // gl_FragColor = vec4(vec3( preview_value), 1.); return; // スケールアニメーションのみ
    
    float pattern_margin = 0.15;
    float value = smoothstep( adjust_progress - 0.05, adjust_progress, 0.95 * (animation_rule - pattern_margin));
    
    // タイルにばらつきを与える
    value *= mix(.5, 1., random(tile_pos));          
    // gl_FragColor = vec4(vec3(value), 1.); return;
      
    
    // コースティクスアニメーション
    float caust = caustics(st, u_time * 0.3);
    // gl_FragColor = vec4(vec3(caust), 1.); return;
    
    
    // リフレクションアニメーション
    float interval_time = 2.; // 光線が通る時間の間隔
    float gap_pos = st.x * 2.0 + st.y; // 斜めにずらす
    float speed = 8.0;
    float line_width = 1.;
    
    float dist_to_line = mod(gap_pos - u_time * speed, interval_time * speed);
    float line = (1.0 - step(line_width, dist_to_line));
    float decay_line = line - (line_width - dist_to_line) * line;
    // gl_FragColor = vec4(vec3(decay_line), 1.); return;
    
    
    // カラーの作成(レインボーパターン)
    float gap_pos0 = (st.x + st.y); // 斜めにずらす
    vec3 shift = vec3(200.0 * (gap_pos0 + u_time), 1.0, 1.0);
    vec3 rainbow = shift_col(vec3(0.5, 0.3, 0.2), shift);
    // gl_FragColor = vec4(rainbow, 1.); return;
    
    
    // テクスチャの作成(金属のサンドノイズ)
    float sand_noise = mix(.7, 1., random(st));
    // gl_FragColor = vec4(vec3(sand_noise), 1.); return;
    
    
     gl_FragColor = vec4(vec3(value + caust + decay_line) * rainbow * sand_noise, 1.); 
}



おわりに

今回はmusicLineの記念動画(ランキング・急上昇・殿堂入り)のデザインを紹介しました。共有動画をもっと美しくしたいと軽い気持ちで手をつけましたが、良い感じにエフェクトを付けることは思ったより大変でした。orz

今回も内部処理をがっつり紹介してしまいましたが、ざっくりイメージが伝われば良いなと思います。奥深いShaderの世界を知って、少しでもプログラミングに興味を持っていただければ嬉しいです。

あと今回は省略しましたが、殿堂入りの記念動画は最後に特殊な処理が施されています。また機会があればOpenGLのFBOという機能について紹介します。


え、そんなマニアックな機能興味ない?
まあそう言わず〜 ( ◜ᴗ◝)و



記念動画デザインの観察(コンテスト編)

musicLineの記念動画(コンテスト)のデザインを紹介。
重ねているエフェクトを観察し、内部の実装をちょこっと紹介します。

コンテストの記念動画



はじめに

musicLineコミュニティでは投稿された曲(MIDI)を再生する時に、視覚的にわかりやすくなるように音符に合わせて可視化した映像が流れます。
その映像が、コンテスト等で上位に上がった曲はキラキラしたリッチな表現の映像になります。

詳しくはこちら


今回はその映像をどのようにして作成しているのかについて観察し、内部実装(GLSLのコーディング)について軽く触れます。



デザインの観察

コンテストの記念動画は各部門で1~8位まであります。
各々のバリエーションを比較して、記念動画の要素を分解することでデザインを観察します。

バリエーションの比較

一般部門とビギナー部門の1~4位の記念動画を比較します。

一般部門 ビギナー部門

各々の記念動画を比較すると

  • 順位ごとにテーマカラーがある
  • 一般部門とビギナー部門でテクスチャが異なる
  • 3位以上はより華やなエフェクト

等の違いがあります。


以下に紹介させて頂いた曲のアプリリンクを載せています。
気になった曲はmusicLineで聴いてください ♪

一般部門 ビギナー部門

一般部門 ビギナー部門


要素の分解

この記念動画を分解すると、4つの要素(レイヤー)で構成されています。

名前 イメージ
エフェクト
情報
コンテンツ
背景

この内、エフェクトと背景を変えることで記念動画のバリエーションを出しています。

エフェクト(コンテスト)



エフェクトの実装

今回はコンテスト記念動画を単純化したキラキラエフェクトの実装を紹介します。AndroidはOpenGLESが動作するため、実装はGLSLを使いました。

キラキラエフェクト

なお、サンプルはWebでGLSLを実行できるエディタ** The Book of Shaders Editor **で表示して確認しました。


エフェクトの構成要素

キラキラエフェクトはタイル状に十字のパターンを敷き詰め、アニメーションするカラーとアルファをブレンドすることで実現しています。

構成要素 イメージ
パターン
カラー
アルファ


パターンの作成

ST座標

precision mediump float;
uniform vec2 u_resolution;

void main() {
    vec2 st = gl_FragCoord.xy/u_resolution.xy; // 正規化
    gl_FragColor = vec4(st, 0., 1.); 
}

左下原点(0, 0)でサイズ 1 の基準となるST座標空間へ変換します。
このサンプルでは座標を可視化するため、gl_FragColorの赤と緑の値にXとYの値を渡して色を表示しています。なので、値が大きくなる程色がつきます。例えば、座標(1, 1)はRGB(1, 1, 0)となり黄色になります。

タイル座標

...
float repeat_count = 30.; // 繰り返し回数
float aspect = .8; // 縦横比
vec2 scaled_st = repeat_count * vec2(1., aspect) * st; // スケーリング 
vec2 difference_st = scaled_st  + vec2(0, .3) * mod(floor(scaled_st).x, 5. ); // ずらす
vec2 tile =  fract(difference_st); // 0 ~ 1 の間を繰り返す
gl_FragColor = vec4(tile, 0., 1.); 

キラキラエフェクトは同じ形が繰り返し描画されるパターンなので、座標をタイル状に分割します。タイルはY軸方向に少しずらすことで単調さを軽減しています。

十字パターン

vec2 tile_center = abs(tile - .5); // 端 0.5 中心 0.
float tile_dist = step(.6, 1. - length(tile_center)) * min(1., step(.42, .5 - tile_center.x) + step(.42, .5 - tile_center.y / aspect));
float  cross =  step(1., tile_dist);
gl_FragColor = vec4(vec3(cross), 1.);

タイルの中心が(0, 0)になるように-0.5します。そして、中心からの距離を使ってなんやかんやで十字を描画します。

(菱形パターン)

vec2 tile_center = abs(tile - .5);    // 端 0.5 中心 0.
float tile_dist = 1. - step(.3, tile_center.x + tile_center.y);
float diamond =  step(1., tile_dist);
gl_FragColor = vec4(vec3(diamond), 1.);

少し処理を変えると菱形になります。


カラーの作成

vec2 tile_pos = floor(difference_st);
vec3 shift = vec3(200.0 * (random(tile_pos * 2.) + u_time * 0.2), 1.0, 1.0);
vec3 random_col = shift_col(vec3(1.0, 0.3, 0.3), shift);
gl_FragColor = vec4(random_col, 1.);

tile_posでタイル毎に色をランダムに決定します。u_timeで時間により色が変化します。
randomshift_colは自作の関数です。

vec3 shift_col(vec3 RGB, vec3 shift) {
    vec3 RESULT = vec3(RGB);
    highp float VSU = shift.z * shift.y * cos(shift.x * 3.14159265 / 180.0);
    highp float VSW = shift.z * shift.y * sin(shift.x * 3.14159265 / 180.0);
    
    RESULT.x = (.299 * shift.z + .701 * VSU + .168 * VSW) * RGB.x
        + (.587 * shift.z - .587 * VSU + .330 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU - .497 * VSW) * RGB.z;
    
    RESULT.y = (.299 * shift.z - .299 * VSU - .328 * VSW) * RGB.x
        + (.587 * shift.z + .413 * VSU + .035 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU + .292 * VSW) * RGB.z;
    
    RESULT.z = (.299 * shift.z - .3 * VSU + 1.25 * VSW) * RGB.x
        + (.587 * shift.z - .588 * VSU - 1.05 * VSW) * RGB.y
        + (.114 * shift.z + .886 * VSU - .203 * VSW) * RGB.z;
    
    return (RESULT);
}

highp float random(highp vec2 st){
    return fract(sin(dot(st.xy ,vec2(12.9898,78.233))) * 43758.5453);
}

GLSLにはランダムに数字を取得する標準関数がないので自作します。こちらもなんやかんやで乱数と色を取得できます。
なおshift_colは色とシフト値を指定しますが、シフト値は(色相、彩度、明度)となっています。色相は360で一周し、彩度と明度は倍率となっています。


アルファの作成

float cycle_time = 5.; // 光る周期時間
float on_rat = .6; // 全体に対して光る割合
float PI = 3.14159265359;
highp float animation_offset = random(tile_pos); // アニメーションの開始オフセット      
float alpha = sin((animation(on_rat, cycle_time, u_time, animation_offset) - 0.25) * PI * 2.) * .5 + .5;
gl_FragColor = vec4(vec3(alpha), 1.);

sinを使うことでアニメーションにイーズインアウト効果をつけてループ再生します。animation_offsetを乱数にすることで、タイル毎にバラバラに光るようにしています。
animationは自作の関数です。

 highp float animation(highp float animation_ratio, highp float cycle_time, highp float time, highp float offset){
     highp float start_time = offset; // 0. ~ 1.
     highp float end_time = start_time + animation_ratio;
     highp float pos_in_cycle = mod(time, cycle_time) / cycle_time; // 0. -> 1.
     highp float pos1 = smoothstep(start_time, end_time, pos_in_cycle);
     highp float pos2 = smoothstep(start_time, end_time, pos_in_cycle + 1.); // 境界用
     return max(fract(pos1), fract(pos2));
 }

アニメーションの開始時間start_timeと終了時間end_timeは0~1+(a)に正規化しています。時間timeと光る周期時間cycle_timeにより、pos_in_cycleサイクル中の進行位置を出しています。アニメーション開始時間がサイクル終了よりの場合、end_timeが1以上になりアニメーションがサイクルを跨ぐ場合があります。そのパターンを考慮して、アニメーション進行位置をpos1pos2の2つを使います。


全コードを表示

#ifdef GL_ES
precision mediump float;
#endif

uniform vec2 u_resolution;
uniform float u_time;

// 乱数を取得する。
highp float random(highp vec2 st){
    return fract(sin(dot(st.xy ,vec2(12.9898,78.233))) * 43758.5453);
}

// shiftにより、色をずらす。
vec3 shift_col(vec3 RGB, vec3 shift) {
    vec3 RESULT = vec3(RGB);
    highp float VSU = shift.z * shift.y * cos(shift.x * 3.14159265 / 180.0);
    highp float VSW = shift.z * shift.y * sin(shift.x * 3.14159265 / 180.0);
    
    RESULT.x = (.299 * shift.z + .701 * VSU + .168 * VSW) * RGB.x
        + (.587 * shift.z - .587 * VSU + .330 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU - .497 * VSW) * RGB.z;
    
    RESULT.y = (.299 * shift.z - .299 * VSU - .328 * VSW) * RGB.x
        + (.587 * shift.z + .413 * VSU + .035 * VSW) * RGB.y
        + (.114 * shift.z - .114 * VSU + .292 * VSW) * RGB.z;
    
    RESULT.z = (.299 * shift.z - .3 * VSU + 1.25 * VSW) * RGB.x
        + (.587 * shift.z - .588 * VSU - 1.05 * VSW) * RGB.y
        + (.114 * shift.z + .886 * VSU - .203 * VSW) * RGB.z;
    
    return (RESULT);
}

 // timeにより、0 ~ 1 の間を繰り返す。
 highp float animation(highp float animation_ratio, highp float cycle_time, highp float time, highp float offset){
     highp float start_time = offset;
     highp float endTime = start_time + animation_ratio;
     highp float time_in_cycle = mod(time, cycle_time) / cycle_time; // 0. ~ 1.
     highp float pos1 = smoothstep(start_time, endTime, time_in_cycle ); // 1-> 0
     highp float pos2 = smoothstep(start_time, endTime, time_in_cycle + 1.); // 1 -> 0 (境界用)
     return max(fract(pos1), fract(pos2));
 }

void main() {
    // ST座標
    vec2 st = gl_FragCoord.xy / u_resolution.xy; // 正規化

    
    // タイル座標
    float repeat_count = 30.; // 繰り返し回数
    float aspect = .8; // 縦横比
    vec2 scaled_st = repeat_count * vec2(1., aspect) * st; // スケーリング 
    vec2 difference_st = scaled_st  + vec2(0, .3) * mod(floor(scaled_st).x, 5. ); // ずらす
    vec2 tile_pos = floor(difference_st);
                
    
    // 十字パターン
    vec2 tile =  fract(difference_st); // 0 ~ 1 の間を繰り返す
    vec2 tile_center = abs(tile - .5); // 端 0.5 中心 0.
    float tile_dist = step(.6, 1. - length(tile_center)) * min(1., step(.42, .5 - tile_center.x) + step(.42, .5 - tile_center.y / aspect));
    float  cross =  step(1., tile_dist);
     
    
    // カラーの作成
    vec3 shift = vec3(200.0 * (random(tile_pos * 2.) + u_time * 0.2), 1.0, 1.0);
    vec3 random_col = shift_col(vec3(1.0, 0.3, 0.3), shift);
    
    
    // アルファの作成
    float cycle_time = 5.; // 光る周期時間
    float on_rat = .6; // 全体に対して光る割合
    float PI = 3.14159265359;
    highp float animation_offset = random(tile_pos); // アニメーションの開始オフセット      
    float alpha = sin((animation(on_rat, cycle_time, u_time, animation_offset) - 0.25) * PI * 2.) * .5 + .5;
    
    gl_FragColor = vec4(random_col * alpha * cross, 1.);
}



おわりに

今回はmusicLineの記念動画(コンテスト)のデザインを紹介しました。 どのようにエフェクトが構成されているか分かったのではないでしょうか?


内部実装を紹介してたら、気分が乗って予想以上に説明してしまった。
ちょこっと紹介のはずががっつり紹介になった。プログラムの知識がないと意味不明だと思うので飛ばし飛ばし見てもらえたらと思います〜(´ε` )

でもShaderはコーディングでリアルタイムで見た目が変わるので面白いです。プログラムに興味があったらまずShaderを触るのがおすすめ。
Webで手軽にできるし〜

あと、Shaderの実装はこの辺りのサンプルを参考にしました。
Shadertoy BETA
GLSL Sandbox Gallery

複雑すぎて何書いてるか解読するのが大変でしたが。。

次はランキングの記念動画デザインについても紹介できたらと思います。



サブテーマ「メモリアル」

musicLine(ver.8.20.0)でリリースするソングビジュアライゼーションのサブテーマメモリアルについての紹介。
作曲の記念になるような機能を考えました。

サブテーマ「メモリアル」の共有動画



はじめに

musicLineでは作曲に対するモチベーションを維持・高めることを目的にコミュニティを運営しています。コミュニティでは、作曲した楽曲を共有し、他の作曲ユーザーとの交流を楽しむことができます。

またランキングやコンテスト等で上位にあがった曲は、ユーザーの反応(いいねや投票等)を多く貰った証としてトロフィーが付与されます。このトロフィーを貰うことが作曲した達成感を高め、モチベーションに繋がると考えています。

今回のメモリアルテーマはトロフィーに加えてより達成感を感じてもらえることを期待して実装しました。ランキング等で上位の曲はメモリアルテーマを選択できるようになり、キラキラの記念動画を作成・共有することができます。
Youtubeみたいに盾を送って表彰できるほどの凄いアプリになれたらいいのですが。。)

ランキングに関して 曲に順位がつくことに違和感や抵抗を感じるかもしれません。ランキングはあくまで一つの指標として楽しんで頂ければ幸いです。上位に上がってこない曲でも素晴らしい曲は沢山あります!逆に埋もれた素晴らしい曲を発掘するのもコミュニティの楽しみ方です♪
順位の是非はどうであれ、一番の願いは作曲を長く楽しんでもらうことです。みんなで盛り上がるという点ではランキングやコンテストのような企画もありだと思っています。



メモリアルテーマ

急上昇とランキング、コンテストで入賞(1 ~ 8 位)または殿堂入りした曲を共有する時に、サブテーマメモリアルを選択できます。

サブテーマメモリアルの選択

順位によってテーマ色やデザインが変化します。 以下にメモリアルテーマの一例を載せます。
(すみません勝手に使ってます。)

急上昇 x スペクトラム
ランキング x ピアノロール
コンテスト x サークル
殿堂入り x シンプル(画像)


以下、アプリリンクです。
(タップするとmusicLineを開いてコミュニティへジャンプします)



ソングビジュアの設定

コミュニティ曲を再生する時、ソングビジュアライザーション設定でサブテーマはダークライトが選択されていますが、上位の曲を再生する時は自動的にメモリアルになります。

自動的にメモリアルテーマへ変更

ただし、メモリアルテーマではピアノロールが若干見づらくなる等の欠点もあるので、従来通りに再生できるように設定項目を増やしました。

ソングビジュアの設定

  • メモリアルテーマを含める
    上位の曲を再生する時、サブテーマをメモリアルにします。
    上位曲でもダークライトで観たい場合は、設定をOFFにしてください。

  • エフェクトを含める
    ソングビジュアにエフェクトを掛けます。
    メモリアルテーマは付けたいけど画面のチカチカを抑えたい場合は、エフェクトを含めるをOFFにすることでエフェクトのみ切ることができます。

  • 画質
    画質をLow Middle Highの3段階で設定できます。
    曲や端末により描画処理が重くなる場合にフレームレートを抑えていますが、画質を落とすことで描画処理を軽くしてコマ落ちを軽減します。



おわりに

今回はソングビジュアライゼーションのサブテーマメモリアルについて紹介しました。
作曲の記念になったり、曲を作り続けるモチベーションに繋がれば幸いです。

なお、説明用にコミュニティに投稿された曲を使用しています。
掲載で困る方がいれば対応しますのでご連絡ください。



気に入ってもらえるといいですが。。
子供の頃、遊戯王のパラレルレアカードがとても綺麗で喉から手が出るほど欲しかった記憶があります。そのキラキラを眺めているだけで幸せな気分になれます。
そんな感覚でキラキラした記念動画が欲しいと少しでも思って頂ければ嬉しいです。 リリース後にTwitterみた時にメモリアルテーマが多いといいですね。

また反響があれば、メモリアルテーマのデザインについて掘り下げて行きたいと思います。



Observation (iOS17+) の注意点

今回は監視フレームワーク・通知機構のObservationの話。
Observationを使用してメモリが圧迫する状況があったので調査しました。

Observationでメモリが圧迫



はじめに

musicLineではMIDI楽譜を編集するためのデータ構造として、トラックやフレーズ、音符、座標というように階層的にモデルを構築しています。
例えば、曲データは複数のトラック、トラックは複数のフレーズを保持しているような構造です。

https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230513/20230513152016.png
データ階層

詳しくはこちら


このような階層的なデータ構造だと、Viewへ変更通知を送る場合に監視は自身のプロパティだけでなく、下層モデルのプロパティまで広がるため、適切な監視体制が求められます。


Combineの問題点

CombineのObservableObjectでは@Publishedを付与したプロパティが監視対象となりますが、変更通知はインスタンスを保持している上層にしか届きません。
そのため、Viewの層(@ObservedObject@StateObject)まで通知を届けるとなると、親のモデルもObservableObjectにして、通知を繋ぎ込む(リレー的に監視する)必要がありました。

// 子を監視して、子からの変更通知があれば上へ流す
counter.objectWillChange.sink{
    self.objectWillChange.send()
}.store(in: &anyCancellable)

詳しくはこちら


Observationで解決?

iOS17でObservationを使えるようになりました。
CombineのObservableObjectはクラス毎に変更を監視することに対し、Observationの@Observableではプロパティ毎に監視しています。
そのためどれだけModel階層が深くても、Viewでプロパティを使用すれば通知が届くようになりました。

つまり、Combineのように通知を繋ぎ込むことなくViewの層まで変更通知を届けられるため、中間の煩雑なコードが削減できます。

qiita.com

またmusicLineでは、音符やフレーズの追加・削除が頻繁にされるため、その度に繋ぎこむ処理が必要なので監視対象の管理も大変でした。
この動的な監視対象の管理問題もObservationにより解決できましたが、新たな問題がありました。


Observationの問題点

@Observableにより末端モデルの音符を監視します。
移動した音符は黄色になっており、通知が適切にViewに届くことを確認できます。
しかしスクロールするたびに、メモリが増え続ける問題がありました。
試しに、1トラック300フレーズの中に音符約1000個を敷き詰めてメモリ量を確認しました。

Observationでメモリ使用量が増加

スクロールで画面を再描画する度にメモリが上がり続けることがわかります。iPhoneのメモリは8GBとか16GBなので、これは見逃せる増加量ではないですね。

そもそも、音符を配置しただけでメモリが1GBを超えているのも問題です。
ここまで音符を敷き詰めることは稀ではありますが、メモリはなるべく少なく抑えたいところです。

音符は4個のプロパティ(位置とサイズ等)があるため、120万(300 x 1000 x 4)のプロパティを監視している計算になります。
この時点で120万のプロパティを全て監視しようとする使い方が間違えているなあという感じはしました。


そして、Observationの挙動調査へ

musicLineの音符のように膨大なプロパティを監視する状況では、Observationの使用は不適切だと認識しました。(メモリ増加問題を除いても)
しかし他の場面(コミュニティの曲リスト等)で使用できるか確認したかったので、どういうパターンの時にメモリ増加問題が起こるかを調査しました。

なおmusicLineのモデル実装が悪い可能性があるので、Observationの最小限サンプルを作成しました。




Observationの挙動調査

調査では通知を上層へ繋ぐことを想定して、MVVMでCounterを実装しました。ただしメモリ増加がわかりやすくなるように、1000個のCounterViewをリストで表示しています。
また比較対象にCombineでも同様に実装しました。

CounterView

CounterViewのリスト


メモリが安定するパターン

Observation(上)とCombine(下)

30MBから始まり、下へスクロールすることで45MBまで増加しますが、その後は何度かスクロールを繰り返しても50MB前後で安定しました。
最初の下スクロールでメモリ増加する理由は、Listで表示しているので最初のスクロールでCounterViewCounterViewModelを作成していると思われます。
その後はCounterViewの作成は行われないためメモリが安定します。


Observationの実装

View

struct Page: View {
    let counters = (0..<1000).map{ _ in Counter() }
    var body: some View {
        List(0..<1000) { num in
            let counter = counters[num]
            CounterView(label: "Counter\(num)", viewModel: .init(counter: counter))
        }
    }
}
struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        HStack {
            Text("\(label):")
            Text("\(viewModel.count)")
            Button("+") { viewModel.increment() }
        }
    }
}

ViewModel

class CounterViewModel {
    var count: Int {
        counter.count
    }
    private let counter: Counter

    init(counter: Counter) {
        self.counter = counter
    }
    
    func increment() {
        counter.increment()
    }
}

Model

@Observable
class Counter {
    var count = 0

    func increment() {
        self.count += 1
    }
}


Combineの実装

View

struct CounterView: View{
    ...
    @ObservedObject var viewModel: CounterViewModel
    ...
}

ViewModel

class CounterViewModel: ObservableObject {
    ...
    private var anyCancellable = Set<AnyCancellable>()
    
    init(counter: Counter) {
        self.counter = counter
        counter.objectWillChange.sink{
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
    }
}

Model

class Counter: ObservableObject {
    @Published var count = 0
    ...
}



メモリ増加になるパターン

前のパターンでは下スクロール後はCounterViewの作成も再描画も行われませんでした。
次はCounterViewの再描画が頻繁に行われるパターンで検証してみます。
今回はGeometryReaderにより、スクロールに応じてアニメーションを付けることで、頻繁に再描画する状況を作りました。

struct CounterView: View{
    let label: String
    let viewModel: CounterViewModel
    
    var body: some View {
        GeometryReader{ geometry in
            let shift = sin(geometry.frame(in: .global).minY / geometry.size.height) * 10
            let x = geometry.size.width * 0.5 - shift
            let y = geometry.size.height * 0.5
                HStack {
                    Text("\(label):")
                    Text("\(viewModel.count)")
                    Button("+") { viewModel.increment() }
                }
                .position(x: x, y: y)
    }
}

Observation(上)とCombine(下)

そうすると、Observation実装の方ではスクロールごとにメモリがどんどん使われることがわかります。どうやらObservableを付与したプロパティのGetterへ頻繁にアクセスすることが原因のようです。

追加検証

試しに、Getterのアクセス(@Observableのaccessの呼び出し)を制限してみます。

@Observable
class Counter {
    var count: Int{
        get {
            if !passingAccess{
                passingAccess = true
                access(keyPath: \._count )
            }
            return _count
        }
        
        set {
            passingAccess = false
            withMutation(keyPath: \._count ) {
                _count  = newValue
            }
        }
    }

    @ObservationIgnored
    var _count = 0

    @ObservationIgnored
    var passingAccess = false
}

accessの呼び出しを制限してメモリ増加を抑える

そうすると、メモリ増加が収まりました。
ただし、この方法では適切な通知はできないようです。

スクロール後の通知はされない



Observation注意点のまとめ

GeometryReaderで座標によりアニメーションするような場合はObservationによるプロパティ監視は控えた方がいいです。そのような場合はViewの再描画によりGetterに頻繁にアクセスしますが、それがメモリ増加が止まらない問題に繋がっていると考えられます。
ObservationではプロパティのGetterで監視イベントを登録するような仕組みになっているため、このような現象が起こるかもしれません。

そもそもSwiftUIでは必要のないViewの再描画を極力抑える思想なので、再描画が頻繁に行われる設計が問題なのかもしれません。
でもスクロールによるアニメーション等で再描画が頻繁に行われるケースは結構ありそうな気もします。




監視フレームワークを再考

Observationの問題がわかりましたが、結局musicLineの音符のように膨大なプロパティを監視するための適切なフレームワークはどうするべきでしょう。もう一度Combineを使う方針に戻って考えてみます。

Combineでは次の観点で問題になることがあります。

  • メモリの圧迫
  • 監視対象の管理が大変


これは特に

  • モデル階層が深い
  • 監視するプロパティが膨大
  • 監視対象が動的に変化

といった特徴のときに起こります。


EventBus

Combineは膨大なモデルを全てObservableObjectとして監視するため、メモリが圧迫します。さらに、階層が深くなることで通知を繋ぎこむ処理が必要になります。

この問題を解消するためにObservableObjectは1個のオブジェクトのみにしました。つまり監視するオブジェクトはEventBusのみにして、EventBusを仲介して通知を送ります。

greenrobot.org

EventBusを介して通知

中間の通知を繋ぎこむコードがなくなり、メモリもCombineの実装より3MBほど軽減されました。

ただし、EventBusだと不必要なViewを再描画してしまう欠点があります。EventBusは監視を1箇所にまとめるため、カウントアップしたCounterViewのみを再描画することはできず、再描画するときは必ず画面全体になります。またEventBusは通知を飛ばせるので便利ですが、基本的にはモデルの構造を無視するシングルトンみたいなものなので、あまり多用すると処理が追えなくなるのも注意です。


実装

EventBusの実装を単純化するためにシングルトンでDIしています。

View

struct Page: View {
    ...
    @StateObject private var eventBus = EventBus.shared
    ...
}

Model

class Counter {
    var count = 0 {
        willSet {
            eventBus.send()
        }
    }
    private let eventBus = EventBus.shared
}

class EventBus: ObservableObject {
    static let shared = EventBus()
    
    func send(){
        objectWillChange.send()
    }
}


なおサンプルでは簡易的な実装ですが、Eventの種類やプロパティを送れるようにSwiftで実装しているライブラリがありました。

swiftpackageindex.com


検証用のサンプル全コードを表示

import SwiftUI

struct Page_Observation: View {
    let counters = (0..<1000).map{ _ in Counter_Observation() }
    var body: some View {
        content(CounterView_Observation.self, counters: counters)
    }
}

struct Page_Combine: View {
    let counters = (0..<1000).map{ _ in Counter_Combine() }
    var body: some View {
        content(CounterView_Combine.self, counters: counters)
    }
}

struct Page_EventBus: View {
    let counters = (0..<1000).map{ _ in Counter_EventBus() }
    @StateObject private var eventBus = EventBus.shared
    
    var body: some View {
        content(CounterView_EventBus.self, counters: counters)
    }
}

fileprivate func content<TCounterView: CounterView, TCounter: Counter>( _: TCounterView.Type, counters: [TCounter]) -> some View where  TCounter == TCounterView.TViewModel.TCounter {
    List(0..<1000) { num in
        let counter = counters[num]
        TCounterView(label: "Counter\(num)", viewModel: .init(counter: counter))
            .buttonStyle(.borderless)
    }
}
import SwiftUI

protocol CounterView: View {
    associatedtype TViewModel: CounterViewModel

    var label: String { get }
    var viewModel: TViewModel { get }
    
    init(label: String, viewModel: TViewModel)
}

struct CounterView_Observation: CounterView{
    let label: String
    let viewModel: CounterViewModel_Observation
    
    var body: some View {
        animationContent(label: label, viewModel: viewModel)
    }
}

struct CounterView_Combine: CounterView{
    let label: String
    @ObservedObject var viewModel: CounterViewModel_Combine
    
    var body: some View {
        animationContent(label: label, viewModel: viewModel)
    }
}

struct CounterView_EventBus: CounterView{
    let label: String
    let viewModel: CounterViewModel_EventBus
    
    var body: some View {
        animationContent(label: label, viewModel: viewModel)
    }
}

fileprivate func animationContent(label: String, viewModel: some CounterViewModel) -> some View{
    GeometryReader{ geometry in
        let shift = sin(geometry.frame(in: .global).minY / geometry.size.height) * 10
        let x = geometry.size.width * 0.5 - shift
        let y = geometry.size.height * 0.5
        content(label: label, viewModel: viewModel)
            .font(.body.monospacedDigit())
            .position(x: x, y: y)
    }
}

fileprivate func content(label: String, viewModel: some CounterViewModel) -> some View{
    HStack {
        Text("\(label):")
        Button("-") { viewModel.decrement() }
        Text("\(viewModel.count)")
        Button("+") { viewModel.increment() }
    }.font(.body.monospacedDigit())
}
import SwiftUI
import Combine

protocol CounterViewModel {
    associatedtype TCounter: Counter
    
    var counter: TCounter { get }
    
    init(counter: TCounter)
}

extension CounterViewModel {
    
    var count: Int {
        counter.count
    }
    
    func increment() {
        counter.increment()
    }
    func decrement() {
        counter.decrement()
    }
}

class CounterViewModel_Observation: CounterViewModel {
    
    let counter: Counter_Observation
    
    required init(counter: Counter_Observation) {
        self.counter = counter
    }
}

class CounterViewModel_Combine: ObservableObject, CounterViewModel {

    let counter: Counter_Combine
    private var anyCancellable = Set<AnyCancellable>()
    
    required init(counter: Counter_Combine) {
        self.counter = counter
        counter.objectWillChange.sink{
            self.objectWillChange.send()
        }.store(in: &anyCancellable)
    }
}

class CounterViewModel_EventBus: CounterViewModel {
    
    let counter: Counter_EventBus
    
    required init(counter: Counter_EventBus) {
        self.counter = counter
    }
}
import Foundation

protocol Counter: AnyObject {
    var count: Int { get set }
}

extension Counter {
    func increment() {
        self.count += 1
    }

    func decrement() {
        self.count -= 1
    }
}

@Observable
class Counter_Observation: Counter {
    var count = 0
}

class Counter_Combine: ObservableObject, Counter {
    @Published var count = 0
}

class Counter_EventBus: Counter {
    var count = 0 {
        willSet {
            eventBus.send()
        }
    }
    private let eventBus = EventBus.shared
}


class EventBus: ObservableObject {
    static let shared = EventBus()
    
    private init() {}
    
    func send(){
        objectWillChange.send()
    }
}




おわりに

今回はObservationの挙動について調査して注意点をまとめました。
Observationは監視フレームワークとして便利ですが、現時点ではGetterに頻繁にアクセスするような場合はメモリ増加が止まらない問題があるようです。
この問題を除いても、監視対象が膨大にある状況では全てプロパティを監視することになるのでメモリ圧迫に繋がります。 その場合は、従来通りCombineやEventBusのような方法を考えるといいと思います。

今回の結果を踏まえて、musicLineにEventBusの仕組みで通知機構を組み込んだのがこちら

改善後のメモリ

なんということでしょう。
1GB以上使用していたメモリが100MB以下に抑えられています。
フレームワークは慎重に選ばないと大変なことになることがわかりました。
ともかく問題が解決してよかった。