- 投稿日:2020-07-12T23:48:44+09:00
生演奏×オーディオリアクティブ、人数無制限のVRライブシステムを作ってライブしたときのシステム構成と実装
はじめに
ぴぼ(@memex_pibo)と申します。
「memex」というVR空間で活動する2人組音楽ユニットをしています。
5月末ごろ、「Omnipresence Live」というVRライブシステムを作って「#解釈不一致」というタイトルのライブをしました。
生演奏に対してオーディオリアクティブな空間演出が行われて、かつ人数制限の無いライブという、類例の少ないVRライブになったと思います。
このエントリでは、その実装や運用を解説します。アーカイブ
「#解釈不一致」の空間アーカイブはVRChatのワールドとして公開してあります。
こちらから体験いただけます。所要時間は50分程度です。
https://vrchat.com/home/world/wrld_21a48553-fd25-40d0-8ff0-b4402b36172aまた、YouTube上に360°ステレオ映像のアーカイブを用意しております。
https://youtu.be/CIahS2Z1_Dsご覧いただいた方が理解しやすいと思いますので、是非一度観て頂きたいです。
システム図
「#解釈不一致」を行った際のシステム図です。
遠隔地にいるmemexの2人がネット越しに音声とモーションを共有し、それを一度映像に変換してからVRChatのワールドに配信するという形になっています。
解説の流れ
まずライブを実現する技術のコアとなる「Omnipresence Live」の実装について解説し、さらにその具体的な運用として「#解釈不一致」の事例を紹介します。
「Omnipresence Live」の解説トピック
- オーディオの音量をOSCに変換する
- MIDI信号をOSCに変換する
- OSCの値をUnityで受けてRenderTextureに書き込む
- 映像として情報を送信したい時の映像エンコード対策
- アバターの頂点アニメーションテクスチャをリアルタイムに映像化する
- 頂点情報が埋め込まれたテクスチャからアバターのモーションを再構成する
- Quaternionを用いてShaderのみでオブジェクトの回転を表現する
「#解釈不一致」の解説トピック
- 生歌とギターの生演奏による遠隔セッション
- 楽曲を構成するトラック毎に反応するパラオーディオリアクティブな空間演出
- 生演奏を含む音楽の特定のタイミングで発火する演出イベント管理
- 遠隔モーションキャプチャ
- ギターの生演奏とギターモーション収録の両立
- ツイートのVR空間へのリアルタイム表示
- 遠隔モーション収録時の返しモニタ
- AWSによる専用映像ストリーミングサーバ構築
- VRChatへのOmnipresence Liveリコンストラクタの実装
- VRChat上での空間編集機能
Omnipresence Live の実装
Omnipresence Live とは
VRライブを構成する要素であるアバターのモーション・演出・音声の情報を全て映像に変換して配信し、配信された映像を受け取ってVRライブを再構成するシステムです。
特徴
- 参加人数の制限が無い
- ただし観客が観客全員を視認できるわけではない
- 生歌・生演奏が可能
- 生モーション収録が可能
- パラオーディオリアクティブ(楽曲の各トラックに演出が反応する)な空間演出が可能
解決する課題
生演奏にタイミングが合ったオーディオリアクティブな空間演出のあるVRライブをする方法が(作らないと)ないこと
純粋に解決するなら音声とタイムコードを共有したモーション・演出を配信できるサーバーと対応したクライアントを作る必要がありそうです。手法
モーション・演出の情報を映像に変換することで音声とタイミングが一致した状態でモーション・演出を配信する
仕組み
次の2つのシステムに分けられます。
イベントビジュアライザ:アバターのモーションとVR空間演出のパラメータを毎フレーム画像として書き出す
リコンストラクタ:イベントビジュアライザが生成した画像からアバターの動きとVR空間演出を再構成するイベントビジュアライザ
イベントビジュアライザは、毎フレームアバターのモーション・空間の演出情報を下のような画像にします。
水色で囲われている部分が演出情報、緑色で囲われている部分がモーション情報です。
イベントビジュアライザ・システム構成
- Ableton Live Suite
- 概要
- DAW(作曲などに用いるソフト)
- 役割
- ライブのタイムライン管理
- Max for Liveへの橋渡し
- ライブ音声の処理
- Max for Live
- 概要
- Ableton Liveの各トラック上でMAX/MSP(ビジュアルプログラミング環境)を走らせられるプラグイン
- 役割
- ライブの音声の各トラック(歌、ギター、ドラムのキック、ドラムのスネアなど)の音量をOSCで送信する
- MIDI信号をOSCで送信する
- Unity(演出信号ビジュアライザ)
- 役割
- OSCで受け取った音量・MIDI信号を1920px × 1080pxのテクスチャに書き込む
- Unity(頂点ビジュアライザ)
- 役割
- VRMアバターの頂点の位置情報を一つずつ1920px × 1080pxのテクスチャに書き込む
- Unity(位置・姿勢ビジュアライザ)
- 役割
- 任意のオブジェクトのTransformを1920px × 1080pxのテクスチャに書き込む
Ableton Live Suite
音声をMax for Liveに送ります。
オーディオリアクティブトラック、生歌トラック、生ギタートラックにそれぞれ後述する紫色のMax for Liveプラグインを挿すことでそれぞれのパートの音量・ピッチを個別にOSCに変換します。Max For Liveプラグイン
Max for Liveとは、Ableton Liveと連携可能な音声信号をすごく簡単に扱えるビジュアルプログラミング環境です。
Ableton Live Suiteの各トラックの音声の音量・ピッチ(音階)を算出してOSCで送信します。Unity(演出信号ビジュアライザ)
音量・ピッチ・曲の展開に合わせた演出信号をテクスチャに変換します。
Max for Liveが出すOSC信号を受け取って画像化します。
OSCの受信には https://github.com/hecomi/uOSC を利用しています。精度の要らない値となるべく精度が欲しい値で少し異なるアプローチを取ります。
精度の要らない値
音量の大小のようなざっくりとした値を色の明るさというざっくりとした値に変換します。
具体的には8bit以下(0~255)の精度でいい場合にはこの方法を用います。下記の
OSCVolumeAndPitchVisualizer.cs
をアタッチしたGameObjectを、OSCを送信するトラック分用意しています。
OSCVolumeAndPitchVisualizer.cs
はトラック毎に定めたテクスチャの座標に、トラックの音声を元にした色を書き込みます。
- 音量 = 輝度(HSVのV)
- ピッチ = 色相(HSVのH)わざわざ色を書き込むのにスレッド数1のコンピュートシェーダを呼んでいますが
Texture2D.SetPixels()
でよいと思います。OSCVolumeAndPitchVisualizer.cs/// 簡略化しています using UnityEngine; using uOSC; public class OSCVolumeAndPitchVisualizer : MonoBehaviour { /// 0~1のfloat値を0~255のモノクロ値でピクセルに書き込むコンピュートシェーダ public ComputeShader NormalizedRGBValueTo64pxRGBBrightness; /// 書き込むピクセル位置 public int row, column; /// 書き込むテクスチャ [SerializeField] private RenderTexture output; /// OSCの受信サーバー [SerializeField] uOscServer server; /// 受け取るOSCアドレス [SerializeField] string address; /// volume: 0~1 /// pitch: 0=A(ラ)で0~11の整数 [SerializeField] float volume = 0, pitch = -2; /// hsv(色)値: 0=1 [SerializeField] float h, s = 1.0f, v; void Start() { server.onDataReceived.AddListener(OnDataReceived); } void OnDataReceived(Message message) { if (message.address == (address + "/volume")) { float.TryParse(message.values[0].GetString(), out volume); } else if (message.address == (address + "/pitch")) { // 0 = A, 1 = A# で0~11まで 検出できなかった場合-1が返ってくる float.TryParse(message.values[0].GetString(), out pitch); } } private void Update() { var dt = Time.deltaTime; /// 音量をそのまま明度に v = volume; /// ピッチを色相に(実際にはカクカク変わらないようにLerpAngleで補完処理をかけました) h = pitch / 12.0f; /// HSVカラーをRGBに変換 var rgb = Color.HSVToRGB(h, s, v * v); SetBlockRGB(row, column, rgb); } /// <summary> /// 指定したブロック(8px × 8px)を指定した色で塗る /// </summary> /// <param name="rowInMethod">ブロックの行</param> /// <param name="columnInMethod">ブロックの列</param> /// <param name="rgbInMethod">色</param> private void SetBlockRGB(int rowInMethod, int columnInMethod, Color rgbInMethod) { // 呼びたいカーネル(処理)を決める var kernel = NormalizedRGBValueTo64pxRGBBrightness.FindKernel("CSMain"); // 必要なデータやら参照やらを渡す NormalizedRGBValueTo64pxRGBBrightness.SetInt("row", rowInMethod); NormalizedRGBValueTo64pxRGBBrightness.SetInt("column", columnInMethod); NormalizedRGBValueTo64pxRGBBrightness.SetFloat("normalizedRed", rgbInMethod.r); NormalizedRGBValueTo64pxRGBBrightness.SetFloat("normalizedGreen", rgbInMethod.g); NormalizedRGBValueTo64pxRGBBrightness.SetFloat("normalizedBlue", rgbInMethod.b); NormalizedRGBValueTo64pxRGBBrightness.SetTexture(kernel, "OutPosition", output); // コンピュートシェーダを実行する NormalizedRGBValueTo64pxRGBBrightness.Dispatch(kernel, 1, 1, 1); } }Normalized8bitRGBValueTo64pxRGB.c// csから渡される値 RWTexture2D<float4> OutPosition; int row,column; float normalizedRed, normalizedGreen, normalizedBlue; [numthreads(1,1,1)] void CSMain (uint3 id : SV_DispatchThreadID) { for(uint x=0; x < 8; x++){ for(uint y=0; y < 8; y++){ OutPosition[uint2((row) * 8 + x, (column) * 8 + y)] = float4(normalizedRed, normalizedBlue, normalizedGreen, 1.0); } } }なるべく精度が欲しい値
情報量の限界
たとえばオブジェクトのx座標を8bit=0~255の値を用いて100=1mとして表現すると、2.56mの距離を1cmずつしか動かせないことになります。
ほかにも、25秒かけてゆっくりフェードさせたいといった場面で8bitしか表現できないと1秒につき値を約10回しか更新できず、カクカクした見た目になります。(実際にはエンコードでさらにカクカクになります)もっと精度が欲しい場合は、エンコードで情報が削がれないように気を使って値をピクセルに書き込む必要があります。
映像エンコード問題
パラメータを画像に変換・映像として配信し、受信した映像からパラメータを復元するにあたって問題となるのが映像のエンコードです。
人間が気付きにくい形で色のデータ量を圧縮することで配信・受信の通信を楽にしてくれる処理です。
が、これが正確な値を送りたい時の足枷となります。一般的に、デジタルな色は赤、緑、青の3色の度合い=RGB値で表現されています。
画像・映像データにおいては、1ピクセルは赤、緑、青それぞれ0~255の2^8=8bitの合計24bitで表すことが多いです。仮に1ピクセル24bitの色を1920px × 1080pxの画素数、30fpsの非圧縮映像にすると1秒当たりに必要なbit数は
24*1920*1080*30 / 1024^2 ≒ 1423Mbps
になります(値がでかすぎて計算が合ってるか不安になります)
が、一般的な映像ストリーミングサイト1において1920px × 1080pxの映像は3~6Mbpsでやりとりされています。
エンコーダが頑張って圧縮してくれているおかげですね。エンコード対策:ブロックと明るさ8bit
エンコードに対してある程度頑健な情報の送り方として、試行錯誤した結果下記の方法を取りました。(H.264エンコードの仕様をちゃんと読めばもっといい方法はあると思いますが……)
- なるべく4px × 4px
、8px × 8px
のようなブロックに1つの情報をまとめて書き込む
- 色は使わずに明るさだけ、つまり0~255の8bitで値を表現する8bitより大きい情報を2つの色に分割して埋め込む: 棄却案
8bitより大きい値を扱いたい場合は2ブロック以上を使って1つの値を表現することになります。が……
8bit=255段階の明るさすらもエンコードのためにノイズが混じった値になります。16bit値を表現するため、はじめは2ブロックを使って1つめのブロックに前半8bit、2つめのブロックに後半8bit、のように分割することを試みました。
が、誤差が非常に大きくなってしまいました。たとえば下記のような送りたい値
21,855
があったとします。
このとき、エンコードによってブロック1の値が
3
だけ変わってしまったとすると、結果は21,087
になります。
ブロック1の値が85
から82
、わずか3/255
が変わっただけですが、2ブロックの合計としては3*256=768
変わることになります。
オブジェクトのx座標をこの2ブロックが保持する16bit=0~65535の値を用いて10000=1mとして表現した場合を考えると、エンコードによる1ブロックの明るさ
3
の誤差で簡単に約8cmズレてしまうことになります。8bitより大きい情報を2つの色に分割して埋め込む: 採用案
そこで、2ブロックに交互に1bitずつ担当させるという方法を取ります。
21,855
はこのように表せます。
ブロック1の値が3
変わって3
から6
になりましたが、値は21,885
で、誤差が30
で済みました。10000=1mなら3mmですね。
実装
実装はこんな感じです。(単なるビット演算です)
(本当は拡張子は.compute ですがQiitaでハイライトされなくて読みにくいので.cとしています 以下同様)Writer8x16px16bitUnsignedInt.c// csから渡される値 RWTexture2D<float4> OutPosition; float val; int row; int column; float4 oddBitOfUintTo8bitBrightness(uint posMillimeter){ // 0bo-p-q-r-s-t-u-v- -> 0b00000000opqrstuv にする uint goal = 0; goal = (posMillimeter & 2) == 2 ? 1 : goal; goal = (posMillimeter & 8) == 8 ? goal | 2 : goal; goal = (posMillimeter & 32) == 32 ? goal | 4 : goal; goal = (posMillimeter & 128) == 128 ? goal | 8 : goal; goal = (posMillimeter & 512) == 512 ? goal | 16 : goal; goal = (posMillimeter & 2048) == 2048 ? goal | 32 : goal; goal = (posMillimeter & 8192) == 8192 ? goal | 64 : goal; goal = (posMillimeter & 32768) == 32768 ? goal | 128 : goal; return float4( goal & 0xff, goal & 0xff, goal & 0xff, 0) / 255.0; } float4 evenBitOfUintTo8bitBrightness(uint posMillimeter){ // 0b-o-p-q-r-s-t-u-v -> 0b00000000opqrstuv にする uint goal = 0; goal = (posMillimeter & 1) == 1 ? 1 : goal; goal = (posMillimeter & 4) == 4 ? goal | 2 : goal; goal = (posMillimeter & 16) == 16 ? goal | 4 : goal; goal = (posMillimeter & 64) == 64 ? goal | 8 : goal; goal = (posMillimeter & 256) == 256 ? goal | 16 : goal; goal = (posMillimeter & 1024) == 1024 ? goal | 32 : goal; goal = (posMillimeter & 4096) == 4096 ? goal | 64 : goal; goal = (posMillimeter & 16384) == 16384 ? goal | 128 : goal; return float4( goal & 0xff, goal & 0xff, goal & 0xff, 0) / 255.0; } [numthreads(1,1,1)] void CSMainFHD (uint3 id : SV_DispatchThreadID) { uint uintValue = val * 65535.0f; for(uint x=0; x < 8; x++){ for(uint y=0; y < 8; y++){ OutPosition[uint2((row) * 8 + x, (column) * 8 + y)] = oddBitOfUintTo8bitBrightness(uintValue); OutPosition[uint2((row + 1) * 8 + x, (column) * 8 + y)] = evenBitOfUintTo8bitBrightness(uintValue); } } }Unity(頂点ビジュアライザ)
頂点アニメーション
アバターの頂点群の位置情報を1頂点ずつ先述の方法でテクスチャに書き込みます。
Vertex Animation Textureと呼ばれる、アニメーションの各キーフレームにおける頂点位置をテクスチャに書き込んで再構成する手法を映像で行う形です。
実装にあたり、sugi-cho様のリポジトリ「Animation-Texture-Baker」を多大に参考にしました。
https://github.com/sugi-cho/Animation-Texture-Baker制約
Unityにおいて頂点の位置情報は32bit floatで表現されていますが、先述の理由からその精度を保証することは難しいため、範囲制限を設けることで16bit値で表現しています。
具体的には下記のような形で表現しました。
- 頂点位置は-3.2767m ~ 3.2767mの範囲のみを動けるものとする
- この範囲を超えた頂点は描画されなくなる
- 位置の値に3.2767mのオフセットを加え、unsigned intの16bit値0~65535
で位置を表現する出力イメージ
下図テクスチャの一番下の白い横長のかたまりがアバター1体分の頂点情報です。
頂点情報を描画している1部分(左下緑色の四角領域)をクローズアップするとこのようになっています。
1頂点につきx,y,z座標をそれぞれ4px × 4pxのブロック2つに書き込んでいます。
実装
実装はこんな感じです。
RealtimeVertexBaker16bitUnsignedInt.cspublic class RealtimeVertexBaker16bitUnsignedInt : MonoBehaviour { public ComputeShader infoTexGen; public Material material; // テクスチャのどの位置に書き込むかというオフセット public int columnOffset=0; private SkinnedMeshRenderer _skin; private int vertexCount; private const int TEX_WIDTH = 1920, TEX_HEIGHT = 1080; [SerializeField] private RenderTexture pRt; private Mesh mesh; private List<Vector3> posList; private ComputeBuffer posBuffer; private void Start() { // アバターのSkinned Mesh Rendererを取得する _skin = GetComponent<SkinnedMeshRenderer>(); vertexCount = _skin.sharedMesh.vertexCount; mesh = new Mesh(); // レンダーテクスチャを書き込み可能にする pRt.enableRandomWrite = true; } void Update() { // SkinnedMeshRenderから現在のフレームのmeshを作る _skin.BakeMesh(mesh); // コンピュートシェーダーに値を渡す入れ物をつくる // C言語の動的メモリ確保みたいな感じで、頂点数 * Vector3のサイズのバッファをつくる posBuffer = new ComputeBuffer(vertexCount, System.Runtime.InteropServices.Marshal.SizeOf(typeof(Vector3))); // meshの頂点位置情報をセットする posBuffer.SetData(mesh.vertices); var kernel = infoTexGen.FindKernel("CSMainFHD"); // 必要なデータや参照を渡す infoTexGen.SetInt("VertCount", vertexCount); infoTexGen.SetInt("ColumnOffset", columnOffset); infoTexGen.SetBuffer(kernel, "Pos", posBuffer); infoTexGen.SetTexture(kernel, "OutPosition", pRt); // コンピュートシェーダを実行する // 引数はスレッド数 // スレッド数は 頂点数 * 1 * 1 infoTexGen.Dispatch(kernel, vertexCount, 1, 1); posBuffer.Release(); } }VertexWriter16bitUnsignedIntFHD.c// csから渡される値 RWTexture2D<float4> OutPosition; StructuredBuffer<float3> Pos; int VertCount; int ColumnOffset; [numthreads(1,1,1)] void CSMainFHD (uint3 id : SV_DispatchThreadID) { // // id.xはそのまま頂点ID // row = id.x % (TEX_WIDTH / 4) // 4はcolumnあたりのx方向の画素数 // column = id.x / (TEX_WIDTH / 4) uint index = id.x; float3 pos = Pos[index]; int TEX_WIDTH = 1920; uint row = index % (TEX_WIDTH / 4); uint column = index / (TEX_WIDTH/ 4) + ColumnOffset; uint posXMillimeter = (pos.x + 3.2767f) * 10000.0f; uint posYMillimeter = (pos.y + 3.2767f) * 10000.0f; uint posZMillimeter = (pos.z + 3.2767f) * 10000.0f; //pos.x1 OutPosition[uint2(row * 4 + 0, column * 6 + 0)] = oddBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 0, column * 6 + 1)] = oddBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 1, column * 6 + 0)] = oddBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 1, column * 6 + 1)] = oddBitOfUintTo8bitBrightness(posXMillimeter); //pos.x2 OutPosition[uint2(row * 4 + 2, column * 6 + 0)] = evenBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 2, column * 6 + 1)] = evenBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 3, column * 6 + 0)] = evenBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 3, column * 6 + 1)] = evenBitOfUintTo8bitBrightness(posXMillimeter); //pos.y1 //省略 //pos.y2 //省略 //pos.z1 //省略 //pos.z2 //省略 }Unity(位置・姿勢ビジュアライザ)
Skinned Mesh Rendererがついたオブジェクトは頂点位置を書き込みましたが、そうでない形が変わらないオブジェクトは単にオブジェクトのPositionとRotationを書き込みます。
RotationはQuaternionのx,y,z,wがそれぞれ-1 ~ 1
の範囲で表現されているので、0 ~ 1
の範囲に正規化して8bit値で書き込みます。リコンストラクタ
イベントビジュアライザが生成した画像から演出・モーションを再構成します。
リコンストラクタをVRChatに実装するため全てシェーダーで記述していますが、特別GPUに頼らなくてもいい場面に関してはC#でRenderTexture.ReadPixels()
を使う方が素直だと思います。演出は任意のシェーダーパラメータを操作する形で表現します。
モーションは頂点情報をテクスチャに書き込んだモデルと同じモデルにアタッチしたシェーダーの頂点シェーダーで画像から頂点位置を復元します。演出の再構成
画像の特定のピクセルの色を呼んでシェーダーのパラメータを任意に操作するものです。
エンコード済みの動画から色を読むときは8px × 8px の四角形領域の外枠を捨てて 6px × 6pxの中心を読むと誤差が気持ち減ったような気がするのでそうしています。
ReadBlock.cfloat texelSizeX = (1.0 / 1920.0); float texelSizeY = (1.0 / 1080.0); float4 color = float4(1,1,1,1); // value1 rowとcolumnで指定された8*8正方形の1ブロックを読む // 中心6*6を読んでとりあえず合計する for(uint x = 0; x < 6; x++){ for(uint y = 0; y < 6; y++){ float2 address = float2( // 8pxで1ブロックなのでrow*8, ブロック内の外周は捨てる方が精度が高いと思われる ( (_row) * 8 + 1 + x ) * texelSizeX, ( (_column) * 8 + 1 + y ) * texelSizeY ); color += tex2Dlod(_valueTex, float4(address.x, address.y, 0, 0)); } } // 合計を36で割って平均化する color = color / 36.0;2ブロックから16bit値を読みたい時は下記のようなコードを用いました(単にビット演算です)
Unpack.cfloat unpackUintFromDoubleFloat4(float4 oneSecond, float4 twoSecond){ // 8bitの値 oneSecond = 0bxxxxxxxx と twoSecond = 0byyyyyyyy を合わせて 16bit goal = 0bxyxyxyxyxyxyxyxy にする uint4 oS = uint4(oneSecond * 255.0 + 0.5); uint4 tS = uint4(twoSecond * 255.0 + 0.5); uint firstGoal = (oS.x & 1) == 1 ? 2 : 0; firstGoal = (oS.x & 2) == 2 ? firstGoal | 8 : firstGoal; firstGoal = (oS.x & 4) == 4 ? firstGoal | 32 : firstGoal; firstGoal = (oS.x & 8) == 8 ? firstGoal | 128 : firstGoal; firstGoal = (oS.x & 16) == 16 ? firstGoal | 512 : firstGoal; firstGoal = (oS.x & 32) == 32 ? firstGoal | 2048 : firstGoal; firstGoal = (oS.x & 64) == 64 ? firstGoal | 8192 : firstGoal; firstGoal = (oS.x & 128) == 128 ? firstGoal | 32768 : firstGoal; uint secondGoal = (tS.x & 1) == 1 ? 1 : 0; secondGoal = (tS.x & 2) == 2 ? secondGoal | 4 : secondGoal; secondGoal = (tS.x & 4) == 4 ? secondGoal | 16 : secondGoal; secondGoal = (tS.x & 8) == 8 ? secondGoal | 64 : secondGoal; secondGoal = (tS.x & 16) == 16 ? secondGoal | 256 : secondGoal; secondGoal = (tS.x & 32) == 32 ? secondGoal | 1024 : secondGoal; secondGoal = (tS.x & 64) == 64 ? secondGoal | 4096 : secondGoal; secondGoal = (tS.x & 128) == 128 ? secondGoal | 16384 : secondGoal; uint goal = firstGoal | secondGoal; float value = goal; return value; }読んだ色の色相値を使ってオブジェクトを円周上に動かす演出例です。
vert.c// vertシェーダ内 //------RGB to HSV ------- float3 hsv = rgb2hsv(color); //------------------------ float rad = radians(hsv.x*360.0); v.vertex.x += cos(rad); v.vertex.y += sin(rad); //------------------------ v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o;モーションの再構成
各頂点の位置をテクスチャから読んだ位置に差し替えます
vert.cappdata vert (appdata v, uint vid : SV_VertexID) { // テクスチャから値を取ってくるところは演出と同じため省略 // 16bitの0~65536の値を-3.2767m ~ 3.2767mの元の位置情報に直す float posX = unpackUintFromDoubleFloat4(oneSecondPosX, twoSecondPosX) / 10000.0f - 3.2767f; float posY = unpackUintFromDoubleFloat4(oneSecondPosY, twoSecondPosY) / 10000.0f - 3.2767f; float posZ = unpackUintFromDoubleFloat4(oneSecondPosZ, twoSecondPosZ) / 10000.0f - 3.2767; float3 pos = float3(posX, posY, posZ); appdata o; o.vertex = v.vertex; // 頂点位置をテクスチャから読み込んだものに差し替える o.vertex.xyz = pos; o.uv = v.uv; return o; }エンコードノイズの弊害
エンコードによる劣化がなければ上記コードだけで綺麗に復元できます。
(左がテクスチャから復元したメッシュ、右が元のアバター)が、実際にはエンコードによって情報が激しく劣化するので、エンコード済み映像をそのまま読むとこんな感じになります。
いくつかの頂点位置が吹き飛んだ値になってしまったためにその頂点を含むポリゴンが全体を覆い隠すサイズになってしまっています。
すべての頂点位置がおかしくなっているのではなく、中にはいくらか正常なポリゴンも存在しますエンコードノイズ対策
そのため、明らかに外れ値な値をジオメトリシェーダでフィルタリングします。(あまりいい解決方法ではないですが……)
下記の3条件でフィルタリングしました。
- ポリゴン内の辺の比が極端に異なる
- 頂点間の距離が離れすぎている
- 頂点が使わなさそうな位置にいる
実際のコードが以下になります(値を直書きするのはやめましょう……)
Filter.c[maxvertexcount(3)] void geom(triangle appdata IN[3], inout TriangleStream<g2f> triStream) { // このポリゴンを省くか?のフラグ bool isBug = false; // 今見ている三角ポリゴンの各辺の長さを取得します float sideLength0to1 = length(IN[0].vertex - IN[1].vertex); float sideLength1to2 = length(IN[1].vertex - IN[2].vertex); float sideLength2to0 = length(IN[2].vertex - IN[0].vertex); float rateThreshold = 5.0; // フィルター:辺の比がおかしかったら消す isBug = sideLength0to1 > sideLength1to2 * rateThreshold || sideLength1to2 > sideLength2to0 * rateThreshold || sideLength2to0 > sideLength0to1 * rateThreshold ? true : isBug; // フィルター:ある頂点間の距離がx[m]以上あったら float threshold = 0.4; isBug = sideLength0to1 > threshold || sideLength1to2 > threshold || sideLength2to0 > threshold ? true : isBug; // フィルター:頂点が範囲外なら for (int i = 0; i < 3; i++) { appdata v = IN[i]; isBug = v.vertex.x > 1.0 || v.vertex.y > 2.0 || v.vertex.z > 1.0 || v.vertex.x < -1.0 || v.vertex.y < -1.0 || v.vertex.z < -1.0 ? true : isBug; } [unroll] for (int i = 0; i < 3; i++) { // 頂点シェーダからもらった3頂点それぞれを射影変換して通常のレンダリングと同様にポリゴン位置を決める appdata v = IN[i]; g2f o; // isBugフラグがあれば頂点位置を原点に飛ばす(discardでよさそう) o.vertex = isBug ? float4(0,0,0,0) : UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.normal = UnityObjectToWorldNormal(normal); triStream.Append(o); } }位置・姿勢の再構成
Skinned Mesh Rendererでないオブジェクトの位置・姿勢を再構成する際はこちらを用います。
Shader内で画像から読んだQuaternion値を用いてオブジェクトを回転させます。
オブジェクトのローカル座標系における原点から各頂点までのベクトルをそれぞれQuaternionで回転させることでオブジェクトの回転を表現します。rotateWithQuaternion.cfloat4 quatenionAxQuaternionB(float4 qa, float4 qb) { return float4( qa.w * qb.x + qa.x * qb.w + qa.y * qb.z - qa.z * qb.y, qa.w * qb.y - qa.x * qb.z + qa.y * qb.w + qa.z * qb.x, qa.w * qb.z + qa.x * qb.y - qa.y * qb.x + qa.z * qb.w, qa.w * qb.w - qa.x * qb.x - qa.y * qb.y - qa.z * qb.z ); } v2f vert (appdata v, uint vid : SV_VertexID) { // -----------省略------------- float4 quaternion = float4(qx,qy,qz, qw); float4 conjugateQ = float4(-qx, -qy, -qz, qw); // 共役 float4 vertAsQ = float4(v.vertex.x, v.vertex.y, v.vertex.z, 0); float4 rotatedPos = quatenionAxQuaternionB(quatenionAxQuaternionB(quaternion, vertAsQ), conjugateQ); v2f o; o.vertex = UnityObjectToClipPos(rotatedPos); o.uv = v.uv; return o; }#解釈不一致 の運用
ここからは、Omnipresence Liveを用いてどのようにライブ「#解釈不一致」を運用したかについて記載します。
- 概要
- 筆者の所属する2人組アーティスト「memex」のVRライブ
- 制作チーム:
- memex
- アラン(@memex_aran):ボーカル
- ぴぼ(筆者)(@memex_pibo):ギター
- ワールド・エフェクトデザイン
- Mikipom(@cakemas0227)
- 2人は遠隔地の自宅からリアルタイムでセッション
- 演奏しながらモーションキャプチャも行う
- VRChatの複数インスタンスで同時にmemexのライブが行われる
- インスタンスとは空間の単位。通常、ユーザーは同時に1つのインスタンスにしか参加できない。1インスタンスに入れる人数には制限(多くて60くらい)があるので、複数インスタンスで参加可能=人数制限がないということ。
「#解釈不一致」の解説トピック
- 生歌とギターの生演奏による遠隔セッション
- 楽曲を構成するトラック毎に反応するパラオーディオリアクティブな空間演出
- 生演奏を含む音楽の特定のタイミングで発火する演出イベント管理
- 遠隔モーションキャプチャ
- ギターの生演奏とギターモーション収録の両立
- ツイートのVR空間へのリアルタイム表示
- 遠隔モーション収録時の返しモニタ
- AWSによる専用映像ストリーミングサーバ構築
- VRChatへのOmnipresence Liveリコンストラクタの実装
- VRChat上での空間編集機能
システム構成図
配信音声について
遠隔地の演奏者と低遅延でセッションできるNETDUETTOを使って遠隔セッションした音声を配信しました。
ギタリスト側のDAWで伴奏を流しながらギターを演奏し、その音声をNETDUETTOでボーカル側に送ります。
ボーカル側はその伴奏とギターを聴きながら歌い、その音声をNETDUETTOでギタリスト側に送ります。
NETDUETTOはセッションのミックス結果を仮想オーディオ入力デバイスとして出力できる機能があり、これを用いて配信ソフトウェアであるOBS Studioに入力します。オーディオリアクティブ演出
下の映像は、ドラムのキック2回→ドラムのスネア→ギターという順序で音が鳴った時の空間演出です。
このような楽曲を構成する各パートの音に反応する演出を作るために、先述のイベントビジュアライザを用いて各パートの音量を色の明るさで表現して映像として配信しました。
左から順に下記トラックに反応しています。
1. キック
2. スネア
3. ハット
4. ギター(生演奏):ピッチ含む
5. ベース:ピッチ含む
6. 曲毎に任意に差し替える目立つ音
7. 曲毎に任意に差し替える目立たない音
8. ボーカル(生歌):ピッチ含む
9. ハモリ・コーラスなお、上から順に下記用途で4種類の出力をしています。
1. 音量の値そのまま
2. 音量の値を使い、減衰はゆるやかにしたもの
3. 音量の値が累積されていくもの(音を鳴らす度に明るくなって最大になると黒からやりなおし)
4. ピッチを取得しやすいよう輝度最大で固定して表示したもの予め送信するトラック数と役割を定めておくことで、ワールド・エフェクトをデザインして頂いたMikipomさんとのやりとりが円滑になるようにしました。
前提
各パートに反応する演出を作るためには、各パートがバラバラになったパラデータが必要になります。
今回は自分達の楽曲のため各パートの音源を簡単に用意できましたが、そうでない場合もiZotope RX7
といったツールを用いることである程度パートを分離した音声を用意できると思います。ルーティング
NETDUETTOのVSTプラグインでは各演奏者の音声をバラバラに出力する機能があるため、これを用いて遠隔地のボーカルトラック単体を抜き出しています。Ableton Live上での設定
Ableton Live Suiteの各トラックに、音は出力されない状態で各パートの音声を読み込みます。
音が出力されるのは、別途用意した各パートが混ざった伴奏音声、リアルタイムに入力する歌とギターのトラックのみです。
それぞれのトラックに音量をOSCに変換して送信するMax for Liveプラグインを挿すことで、それぞれのトラックから音量の値がOSCで送信されます。
演出イベントの管理
下の映像は、ライブの開始SEが終了して1曲目のイントロが始まり、ワールドそのものが出現する演出です。
このように、オーディオリアクティブな要素とは別に、曲中の特定のタイミングに合わせて演出を行うために、Ableton Liveのタイムライン上に演出イベントのトリガーを配置しました。
トリガーはMIDI Note・MIDI Pitch BendをOSCに変換し、OSCをイベントビジュアライザで映像に載せることで演出イベントを発火させます。制作から実際に再生されるまでの流れ
- デザイン担当のMikipomさんが演出要素をShader Onlyで作る
- デザイン担当のMikipomさんがUnityのTimelineで曲に合わせてシェーダパラメータを操作したデモを作る
- Timelineを元にどのタイミングでどのパラメータをどの値に動かしたいかという進行表を作る
- パラメータは
0~1
の範囲で表現できるようにしておく- 動かしたいパラメータをそれぞれMIDI Note Num(ドレミファソラシド…)に割り当てる
- 進行表を元に筆者がAbleton Live上にパラメータを操作するためのMIDI Noteを配置していく
- それぞれのMIDI Note上でMIDI Pitch Bendを用いて0~1のパラメータを表現する
- OSCでMIDI Note NumとPitch Bendを送信する
- イベントビジュアライザを用いてパラメータをそれぞれ異なるピクセル上に色で表す
- ワールドに配置されたシェーダー側で特定のピクセルの色を読んで演出を再生する
進行表
いつ、どのマテリアルのどのパラメータをどこまで動かすかという進行表です。
演出イベント管理
動かしたいパラメータの数だけトラックを用意しました。
曲の特定のタイミングに合わせてMIDI Noteが配置されています。
演出制御用MIDI Note
あるオブジェクトの幅を表すパラメータを0から1まで動かすMIDI Noteです。
Notesの欄に記載されている音階がマテリアルのパラメータに対応しています。
例えば:
- ラ:アーティストを表示するか否かのフラグ
- シ:ワールド全体の色相
- ド:床の高さモーション収録
ボーカルモーションの遠隔収録
ボーカルのモーション収録はバーチャルモーションキャプチャーとEVMC4Uを用いて行いました。
バーチャルモーションキャプチャーは、SteamVR対応デバイスを用いて簡単にVRMモデルのモーションキャプチャを行い、さらにそのモーションをOSCで送信することができるソフトウェアです。
EVMC4Uは、バーチャルモーションキャプチャーから送られてきたモーション情報をUnity上のVRMモデルにリアルタイムに適用できるスクリプト群です。
トラッキングデバイスはHTC VIVE CEとVIVE Trackerを用いました。
ボーカルPCからバーチャルモーションキャプチャーでモーションをOSCで筆者宅ネットワークのグローバルIPに向けて送信し、筆者PCのEVMC4Uで受け取ります。
この方法を用いると、NETDUETTOの音声と殆どズレのないモーションを再生することができます。ギターを生演奏しながらギターモーションを収録する
M5StickCをギター側、右手側で2つとiPhone11を用いて、ギターの演奏を簡易的にトラッキングします。
M5StickCはディスプレイがついた、加速度センサやWi-Fi接続機能など多くの機能を持つ小型で低価格なマイコンです。
M5StickCでセンシングした値をWi-Fi経由でPCに飛ばすことでアバターのモーションを表現しました。
画像左はローフレットを押さえて右下の方向を見ている筆者、画像右はハイフレットを押さえて左上の方向を見ている筆者です。これは過去にアコースティックカバー生放送を行った時に制作したものです。
実際の動きは下記放送アーカイブからご覧いただけます。
【memex】アコースティック歌生放送! #めめなま 【3000人記念】 - YouTubeギター側
フレット
ギターの指板における左手の位置と、ギターの姿勢をセンシングします。
左手の位置は超音波センサ(HC-SR04)、ギターの姿勢はM5StickC内蔵の加速度センサでセンシングしました。超音波センサは超音波を発しそれが対象にぶつかってから帰ってくるまでの時間を音速で割ることで対象との距離を求めることができるセンサです。
これをギターのヘッド裏に取り付けることで左手の位置をざっくりと計測します。
実際にギターを弾いてみるとわかりますが、抑えるポジションによっては手首はネックの真裏の高さにないこともあり、正確にトラッキングすることはできません。左手が動いていることがわかるくらいではあります。
また、上記画像からわかるように筆者のアバターの手は抽象化されているので、指のあるアバターではさらに別の対策が必要になると思われます。
FinalIKを用いてアバターの手の位置をギターの指板上に固定して動かしています。
FinalIKは逆運動学を用いてアバターの手などを自然に目標の位置に到達させることができるアセットです。画像の手前にある二つの白いSphereが、それぞれ「1Fを押さえているときにアバターの左手があってほしい位置」「12Fを押さえているときにアバターの左手があってほしい位置」です。
左手は、2つのSphereを結んだ直線上を超音波センサの距離値に応じて移動しています。実装はこのようになっています。
ultrasonicAndIMUWifi.c#include <M5StickC.h> #include <WiFi.h> #include <WiFiUDP.h> #include <OSCMessage.h> /** * WiFiアクセス用 */ const char ssid[] = "[お使いのルーターのSSID]"; const char pass[] = "[パスワード]"; static WiFiUDP wifiUdp; static const char *kRemoteIpadr = "[受け取りたいPCのプライベートIP]"; static const int kRmoteUdpPort = 8001; //送信先のポート static const int kLocalPort = 7000; //自身のポート boolean connected = false; /** * HCSR-04用 */ int Trig = 26; int Echo = 36; int Duration; float Distance; /** * IMU用 */ float pitch = 0.0F; float roll = 0.0F; float yaw = 0.0F; float temp = 0; /** * Setup */ static void WiFi_setup() { WiFi.begin(ssid, pass); while( WiFi.status() != WL_CONNECTED) { delay(500); } } static void Serial_setup() { Serial.begin(115200); Serial.println(""); // to separate line } static void Hcsr04_setup() { pinMode(Trig,OUTPUT); pinMode(Echo,INPUT); } void setup() { Hcsr04_setup(); Serial_setup(); WiFi_setup(); M5.begin(); M5.IMU.Init(); } void loop() { /** * 距離計測 */ digitalWrite(Trig,LOW); delayMicroseconds(1); digitalWrite(Trig,HIGH); delayMicroseconds(11); digitalWrite(Trig,LOW); Duration = pulseIn(Echo,HIGH); if (Duration>0) { Distance = Duration/2; Distance = Distance*340*100/1000000; // ultrasonic speed is 340m/s = 34000cm/s = 0.034cm/us OSCMessage msgDistance("/leftHand/distance"); msgDistance.add(Distance); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgDistance.send(wifiUdp); wifiUdp.endPacket(); } /** * IMU計測 */ M5.IMU.getAhrsData(&pitch,&roll,&yaw); M5.IMU.getTempData(&temp); /** * OSCSend */ OSCMessage msgPitch("/guitar/pitch"); msgPitch.add(pitch); OSCMessage msgRoll("/guitar/roll"); msgRoll.add(roll); OSCMessage msgYaw("/guitar/yaw"); msgYaw.add(yaw); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgPitch.send(wifiUdp); wifiUdp.endPacket(); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgRoll.send(wifiUdp); wifiUdp.endPacket(); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgYaw.send(wifiUdp); wifiUdp.endPacket(); delay(33); }ギター姿勢
M5StickCの加速度センサを用いて、ギターの姿勢を特定の軸に限って表現します。
加速度センサはスマートフォンの縦持ち、横持ちなどを判定する際に使われているセンサーで、デバイスの姿勢をある程度正確に計測することができます。(重力が影響しない軸の回転が計測できません)
ギターが地面に対してどの程度垂直かもしくは平行か、を角度で計測して、ギターのrotationに適用しています。右手側
M5StickCの加速度センサを用いて、ギターをピッキングする動きを表現します。
仕組みはギター姿勢と同じです。
FinalIKでアバターの右手をギターのピックアップ上に配置し、rotationだけを操作しています。頭
iPhone 11に搭載されているFaceTrackingを使ってアバターの頭と腰を動かしています。
iPhoneアプリ「ZIG SIM Pro」を用いてFaceTrackingのパラメータをOSCでPCに送信しています。
ZIG-Project https://zig-project.com/FaceTrackingのパラメータから顔の姿勢をあらわす
facerotation
を抜き出しアバターのHeadボーン、Spineボーンに適用しています。
首から下が固定されていて頭だけ動いているのが若干不自然だったので腰も同時に動かしています。実装はこんな感じです。
OSCHeadAndSpineRotator.cspublic class OSCHeadAndSpineRotator : MonoBehaviour { float pitch, roll, yaw; const string uuid = "[ZIG SIM内で確認できるデバイスID]"; private Animator animator; private Transform head, spine; private Quaternion initalRotationl, headInitialLocalRotation, spineInitialLocalRotation, preHeadLocalRotation, preSpineLocalRotation; [SerializeField] Vector3 eularRotationOffset; [SerializeField] float slerpRate = 10f; [SerializeField] uOscServer server; void Start() { server.onDataReceived.AddListener(OnDataReceived); animator = GetComponent<Animator>(); head = animator.GetBoneTransform(HumanBodyBones.Head); spine = animator.GetBoneTransform(HumanBodyBones.Spine); headInitialLocalRotation = head.localRotation; spineInitialLocalRotation = spine.localRotation; } void OnDataReceived(Message message) { if (message.address == "/ZIGSIM/" + uuid + "/facerotation") { Quaternion q = new Quaternion( float.Parse(message.values[0].GetString()), float.Parse(message.values[1].GetString()), float.Parse(message.values[2].GetString()), float.Parse(message.values[3].GetString()) ); var thisFrameHeadLocalRotation = Quaternion.Slerp(preHeadLocalRotation, headInitialLocalRotation * q * Quaternion.Euler(eularRotationOffset), Time.deltaTime * slerpRate); var thisFrameSpineLocalRotation = Quaternion.Slerp(preSpineLocalRotation, spineInitialLocalRotation * q * Quaternion.Euler(eularRotationOffset), Time.deltaTime * slerpRate); // 取得した回転の8割くらい頭を回転させ、4割くらい腰を回転させます(この値は好み) head.localRotation = Quaternion.Lerp(headInitialLocalRotation, thisFrameHeadLocalRotation, 0.8f); spine.localRotation = Quaternion.Lerp(spineInitialLocalRotation, thisFrameSpineLocalRotation, 0.4f); preHeadLocalRotation = thisFrameHeadLocalRotation; preSpineLocalRotation = thisFrameSpineLocalRotation; } } }VR空間へのツイートのリアルタイム表示
配信映像に直接ツイート本文を載せ、リコンストラクタ側でその画像に透過処理をかけて空間に画像として表示しています。
UnityでTwitter APIを利用するためのTwityというライブラリを用いてハッシュタグツイートを約10秒間隔で更新しました。
GitHub - toofusan/Twity: Twitter API Client for Unity C# (ex-name: twitter-for-unity) https://github.com/toofusan/Twity
テキストはディスプレイに直接Canvasで表示しています。出現アニメーションはリコンストラクタ側でつけることもできますが、リコンストラクタ側に状態を持たせたくなかったので、配信映像に載せる前の段階でアニメーションをつけました。
文字アニメーションにはText Juicerというプラグインを用いました。
GitHub - badawe/Text-Juicer: Simple tool to create awesome text animations https://github.com/badawe/Text-Juicer返しモニタ
アバターの姿とUnityのコンソール出力を確認できる返しモニタを用意します。
主に下記2点の目的で返しモニタが必要でした。
- 意図通りのモーションが反映されているか確認するため
- ライブのMC時間にツイートを読み上げるためUnityのマルチディスプレイ機能を用いて、配信用テクスチャを表示するウィンドウと別のウィンドウとして画面に表示します。
ライブ中自分達のアバターのモーションがどのようになっているか、またどんなツイートが観客側に表示されているかを一目で確認できるように、アバター表示に重ねてコンソール出力をCanvasに表示しています。
コンソール出力を表示する実装はこちらの記事を参考にしました。
【Unity】ゲーム画面にDebug.Logを出したい! - うら干物書き https://www.urablog.xyz/entry/2017/04/25/195351返しモニタウィンドウをボーカルのPCにDiscordの画面共有で送ることで、同じ返しモニタを共有できるようにしています。
HLSサーバー
諸々の事情を考慮して映像はAWSで構築した専用のストリーミングサーバーから配信しました。
構成は下記の通りです。下記記事を参考にして殆どその通りの手順で進め、4時間程度で動作確認までできました。(AWSすごい)
OBSとAWS Elemental MediaLiveでライブ配信をしてみた | Developers.IO https://dev.classmethod.jp/articles/live-aws-elemental-medialive-with-obs/
MediaPackage で Amazon CloudFront を使用する - AWS Elemental MediaPackage https://docs.aws.amazon.com/ja_jp/mediapackage/latest/ug/cdns-cf.htmlVRChat上で再生可能にする
- リコンストラクタのシェーダで表現されたライブに必要なオブジェクトをワールドに配置する
- アーティストのモデル、ステージ、GPUパーティクルなど
- VRChat SDKのコンポーネント
VRC_SyncVideoStream
を用いてCloudFrontで配信しているHLS映像のURLにアクセス- 映像をワールド内に配置したカメラで撮影し1920 * 1080のRenderTextureに書き込む
- 各オブジェクトのマテリアルのテクスチャとして3のRenderTextureを指定する
VRChat上で空間編集を可能にする
下の映像のように、だいたいのオブジェクトを手で掴んで再配置できるようにしました。
同じインスタンスにいる観客同士のみに反映されます。
- オブジェクトにVRChat SDKのコンポーネントVRC_Pickup
をつける大変だったこと
解説は以上です。制作して大変だったことをメモしておきます。
シェーダーむずかしい
シェーダーでアニメーションさせるVATというものを使ったらモーションを画像で表現できるらしい、と聞いてシェーダーを調べ始めたものの、全然理解できなかった
- これがなかったら1行も読めるようにならなかったと思う
- Unity Shader Programming Vol.01 (v.2.2.1)【PDF】 - XJINE's - BOOTH https://booth.pm/ja/items/931290
- リファレンスがわからない
- 未だにどう調べたら欲しい情報にたどり着けるかわからない HLSL? CG?つくっていることを秘密にしたい時、だれにも聞けない
12月くらいから制作していたのですが、人に聞けばおそらく一瞬で解決しそうなことで割と詰まってしまいました。
何を作っているかを秘密にしたいときって情報収集の仕方が難しいですよね……エンコードこわい
- H.264エンコードについて詳細に調べようと思ったけど挫折してしまった
- 種類がありすぎる ドキュメントが長すぎる
- ある程度ピクセルをブロックにまとめてやってるっぽいとか彩度は減衰しやすいっぽいという認識だけが残っています
UnityCaptureのピクセルズレ?
UnityCaptureでOBSに入力した画像は微妙にピクセルがズレていて、この用途では使えなかったのでビルドしてOBSのウィンドウキャプチャを使いました
トラブル起きたら一発アウトで再起動が必要になるので、本当に怖かったです……映像ストリーミング、お金かかりすぎでは
#解釈不一致 配信コスト、タイムラグが・・・()
— ぴぼ | memex Gt (@memex_pibo) May 31, 2020
ま、まあライブハウスのノルマ2回分と考えれば余裕ですよ… pic.twitter.com/SSzPBLcuksおわりに
遠くない将来、好きな姿で、オーディオリアクティブな演出が行われるライブ会場で、リアルタイムに遠隔地のメンバーとセッションするVRライブをいちユーザーが開催できて、それに観客が何人でも同時に参加できる未来が来てほしいなと思っています。
2020年7月現在、生演奏しながら楽器演奏のモーションをキャプチャするのが難しいとか、音声と空間演出のタイムコードを合わせるのが難しいといった様々な課題があり、そういったVRライブを開催するのは容易ではありません。
エンジニアとしての自分にとって「#解釈不一致」は、望むVRライブの未来を今いちユーザーとして利用可能な技術で実現する挑戦でした。
いつか「昔はこんな面倒なことやらないとこういうライブできなかったんだな~」と言える未来が来ることを願っています。参考
GitHub - sugi-cho/Animation-Texture-Baker https://github.com/sugi-cho/Animation-Texture-Baker
Unityでスクリプトを使わずに流体を計算する – EL-EMENT blog http://el-ement.com/blog/2018/12/13/unity-fluid-with-shader/
ビット演算まとめ - Qiita https://qiita.com/qiita_kuru/items/3a6ab432ffb6ae506758
ライブ エンコーダの設定、ビットレート、解像度を選択する - YouTube ヘルプ https://support.google.com/youtube/answer/2853702?hl=ja ↩
- 投稿日:2020-07-12T23:44:35+09:00
3D部分の解像度を下げつつもPostProcessingStackV2を使いたい
大元の画像はこちら。
3D描画に、PostProcessingStackV2でBloomとDoFがかかっています。
白い部分が滲んでいる(Bloom)&遠景近景がぼやけている(DoF)ことが確認できます
この画像に、
【Unity】カメラ1つでUI解像度を維持し、3D解像度だけを下げる方法
を試してみた所、何やら違和感が。※解像度は半分で、低解像度を強調するためにFilterMode.Point。
予定通り解像度は半分になった! ・・・けど、
あれ、PostProcessingStackV2の効果が消えてしまっている?
何故か?ということで、FrameDebuggerを確認した所、
モデルの描画については、指定したとおり、自分で用意したDisplayBufferに描画されている。
モデルの描画は意図通りされている様子。
ただ、PostProcessingStackを行っている部分を確認すると…
PostProcessingStackの最初の元画像を取得する部分がUnityWhiteテクスチャになってしまっている…!?
元画像が上手く取得できていないみたい。
いわゆるシェーダのProperties { _MainTex ("Texture", 2D) = "white" {} }の"white"を引っ張ってしまっている様子。
普段のPostProcessingStackなら、
TempBuffer を _TargetPool0 にコピーしているはずなのに…。
…ということで色々確認した所、下記のことがわかってきた。
ここからは低解像度描画をするために別途用意したRenderTextureを低解像度描画用RTと記載する
描画の仕方 モデルなどの描画 PostProcessの元画像取得 camera.SetTargetBuffers(低解像度描画用RT) 低解像度描画用RTに描画される UnityWhiteテクスチャを引っ張ってしまう PostProcessingStackV2 TempBufferに描画される TempBufferを引っ張る つまり、やりたい事を達成するためには下記のどれかを達成する必要がある
- PPSV2を有効にした上でTempBufferのサイズを自由に変更することができれば、3Dのみ低解像度描画が達成できる
- 低解像度描画用RTの画像をTempBufferにコピーする事ができれば、PostProcessに画像を伝える事ができる
- PostProcessがTempBufferではなく低解像度描画用RTを拾う事ができれば、低解像度画像にPostEffectをかけることが出来る
結局色々試した所、1つのカメラではどれも達成できませんでした。
1週間近く、試しては消し、試しては消しで途方に暮れてしまった。。
ので、いっそ方針を変更して、2つカメラを用意してPostProcessingStack用にカメラを分けてみることにしました。
順番としては下記の順序となります。
- メイン描画用カメラ
- SetTargetBuffers()で低解像度描画用RTに書き込むように設定
- AfterEverythingタイミングでCommandBufferでSetRenderTarget(-1)で描画先をフレームバッファにする
- AfterEverythingタイミングで低解像度描画用RTをCameraTargetにコピーする
- PPSV2用のカメラ
- メインカメラ描画の直後に描画するようにdepthを設定
- 念の為、位置や姿勢など、メイン描画カメラと全く同じ表示となるようにしておく
- CullingはPostProcessレイヤのみ表示するようにし、ClearFlagはDon't Clearにしてメイン描画をそのまま使う
- PostProcessLayerを付けておいてポストプロセスをかける
という事で「メイン描画は低解像度描画用RTにモデルなどを描画し、PPSV2用はTempBufferが用意されるだろう」という目論見で試してみた結果がこれ。
DoFが効いていない…??
FlameDebuggerを確認すると、カメラを切り替えたタイミングでUpdateDepthTextureが走って、深度テクスチャが無効になっていることがわかる。
PPSV2用カメラはDon't Clearにしてるのに…。何で深度をクリアしてしまうのか…。
まぁそれでも、カメラを2つにすることでカラー情報をTempBufferに渡すことができた。
という事は、後は深度バッファをPostProcessにわたすことができれば目的が達成できそう…!なので、先程の処理に深度バッファの取得&コピーを追加してみる
- メイン描画用カメラ
- SetTargetBuffers()で低解像度描画用RTに書き込むように設定
- CommandBufferでAfterDepthTextureのタイミングで深度バッファをキャプチャしておく
- AfterEverythingタイミングでCommandBufferでSetRenderTarget(-1)で描画先をフレームバッファにする
- AfterEverythingタイミングで低解像度描画用RTをCameraTargetにコピーする
- PPSV2用のカメラ
- メインカメラ描画の直後に描画するようにdepthを設定
- 念の為、位置や姿勢など、メイン描画カメラと全く同じ表示となるようにしておく
- CullingはPostProcessレイヤのみ表示するようにし、ClearFlagはDon't Clearにしてメイン描画をそのまま使う
- AfterDepthTextureタイミングで、キャプチャ済み深度バッファをBuiltinRenderTextureType.Depthにコピーする
- PostProcessLayerを付けておいてポストプロセスをかける
できた画像がこちら。※わかりやすくするために、3D側解像度は半分にしています。
ちゃんと3D解像度は半分になりつつ、PostProcessingStackV2の効果もかかっていることが確認できます。
FrameDebuggerも確認。
PPSV2用カメラのUpdateDepthTextureの後処理で深度バッファをコピーできていることが見えます。
という事で、カメラ1つで何とかしようとしすぎた事で時間がかかってしまいましたが、なんとか目的達成できました。
同じ苦労をする人が少しでも減ると良いですね。。
- 投稿日:2020-07-12T21:20:04+09:00
Unity + Epic Online Services で オンラインゲームを作ろう
#Unity + #EpicOnlineServices でチャット pic.twitter.com/eaEhziiFoM
— オカ (@okagamedev) July 12, 2020今年5月に Epic Games から Unreal Engine 5 が大々的に発表されましたが、
同時に Epic Online Services (以下 EOS)という
リアルタイム通信対戦ゲームを開発するためのプラットフォームも発表されました。
この分野は Photon と Monobit が有名です。なんと競合製品の Unity にも対応という謳い文句です。
ですが、まだまだサービスが開始されたばかりで公式以外に全く情報がありません。
C# の公式デモはありますが、 Unity のデモはありません。
ユーザーが増えて開発が活発になることを願って、Unity のデモを作成しました。既知の問題
先に重大な問題を書いておきますと、 7/12 現在 ロビー検索が動きません
ロビー検索ができないと、通信相手と同じロビーに入室することができず、P2P 通信を始められません。
公式のバグレポートやフォーラムのスレッドで報告はしておりますが、なかなか修正してもらえません。暫定対処として、ロビー作成時に発行されるロビーIDをクライアント間でどうにか共有することで、ロビーに入室しています。
このままでは Firebase などの外部サービスを立て、ロビー検索を自前で作るしかありません。
公式には早急に直していただきたいところです。筆者環境
OS: Windows 10 Pro
Unity:Unity 2019.4.3f1デモ
下記の Github リポジトリに配置しました。
https://github.com/okamototomoyuki/unity_eos_chatUnity + EOS で作ったチャットになります。
ゲームじゃないじゃんゲームに必要な機能の確認レベルです。
通信部分は P2P で実装しておりますので、
通信データをメッセージから、キャラ座標などの情報に置き換えてゲームに組み込んでいただければです。デモを動かす
Epic Games のアカウントを2つ作成
EOS のユーザー情報には Epic Games のアカウントを使います。
メールアドレスを2つ用意してアカウントを作成してください。
https://www.epicgames.comEOS のクレデンシャル作成
デモを動かすためにはご自身で EOS のクレデンシャルを作成していただく必要があります。
Epic Games Japan が日本語の解説動画を上げてくれております。
12:15~23:30 がクレデンシャル作成の解説です。
先日公開されたEpic Online Services (EOS)の概要、及び実際にSDKをダウンロードしてサンプルを動かす手順を説明します
— アンリアルエンジン (@UnrealEngineJP) May 28, 2020
EOSはどのエンジンにも、どのストアにも、どのプラットフォームにも対応していますので、UE4ユーザ以外の方にもおすすめですhttps://t.co/v9nOPVIBlm #EGJオンラインラーニングUnity で EXE 作成
- Assets/ScriptableObjects/EOSSettings に作成したクレデンシャル情報を入力
- Windows Standalone でビルド
EOS DevAuthTool を起動
ゲーム上でユーザーが EOS にログインするとき、通常はブラウザが起動してIDとパスワードを入力します。
しかし、開発中にこのログイン方法のままでは、テストのたびにテストユーザーの数だけ
「ブラウザ起動」⇒「ログイン」⇒「ログアウト」⇒「ブラウザ起動」⇒「別のユーザーでログイン」⇒…
となり、とても面倒です。この面倒を解決してくれるのが SDK に同梱されている EOS DevAuthTool というツールです。
テストするユーザーをログイン状態に保持してくれます。Assets/ExternalAsset/EOS-SDK-CSharp-13812567-1.7/SDK/Tools/EOS_DevAuthTool-win32-x64-1.0.1
を解凍して中身の EOS_DevAuthTool.exe を実行してください。
ポート番号の入力が求められます。
本デモでは 8765 をデフォルトにしてます。
アカウントをログインさせるページが表示されました。
先に作成した Epic Games のアカウントを2つログインさせます。
credential name は適当な名前を入力してください。
この名前はゲーム側で使用します。
二人分のログインが完了しました。
ツールは起動したままにしてください。デモ実行
EXE を2つ起動します。
ログイン
それぞれ Dev User Name に先ほど EOS DevAuthTool で入力した credential name を入力し、Login を押してください。ロビーの作成と入室
1. 左側で Create Lobby を押す
しばらくすると画面が切り替わり Lobby Id 欄にランダムな文字列が生成されます。
また、この時点で左側はロビーにログインしています。
2. 生成された文字列をコピー
3. 右側の Lobby Id 欄にペースト
4. 右側で Join Lobby を押す
両方ともに Chat と書かれたエリアが表示されたロビー入室完了です。チャットで通信
メッセージを入力して Send ボタンを押してみましょう
もう片方の Receive 欄に表示されるはずです。
P2P 通信成功です。細かな問題
Editor で破棄処理が機能してない
https://dev.epicgames.com/docs/services/ja/CSharp/GettingStarted/index.html
上記 C# の解説ページの「SDK を初期化してクリーンアップする」の章に
完了時に破棄処理を呼ぶようにとありますが、現在(Ver1.7) Unity Editor で機能しません。
Unity Editor を消さずに2回初期化(PlatformInterface.Initialize) を呼び出すと「Result.AlreadyConfigured」エラーになります。
このままでは ゲームを実行するたびに Unity Editor 自体を再起動しなければいけません。https://eoshelp.epicgames.com/s/question/0D52L00004Ss2leSAB/platform-initialization-fails-from-2nd-time-on-unless-i-restart-unity
公式の回答でUnity Editor の場合は下記で回避するように、となりました。
・Result.AlreadyConfigured は無視
・破棄処理(PlatformInterface.Shutdown)呼ばないまとめ
重大なバグが放置されてるため、まだまだ本格利用は難しいですが、
- 無料
- サーバー不要
- ソーシャル機能
- (予定)ストア機能
と成熟すればかなり魅力的なサービスになると期待してます。
よければ皆さんも情報共有どんどんして、サービスを盛り上げていきましょう。
- 投稿日:2020-07-12T19:31:48+09:00
Unityでもクリップボードの画像を使いたい【Windows編】
このように思って調べてみたのですが、2020年7月時点でUnityは、クリップボードの文字列しか扱うことができません。
Scripting API: GUIUtility.systemCopyBuffer - Unity
この機能自体は、ゲーム内でフレンドコードをクリップボードにコピーするために使われているそうです。
しかし、私のように「クリップボードの画像も使いたい!」という人が必ずどこかにいると思うので、今回この記事を投稿します。成果物はこちらのリポジトリです。
https://github.com/umetaman/UnityNativeClipboardまた、一言で「画像」と言ってもたくさんの形式があるので、今回は各チャンネル8ビットで3チャンネルから4チャンネルの画像を対象にします。JPEGとPNG-32です。
環境
- Unity2019.4.3f1(LTS)
- Windows 10 Pro(2004)
- Visual Studio 2019
はじめに
UnityのC#ではサポートされていない機能を使うために、NativePluginを使用します。
簡単に説明すると、
- C, C++, Objective-Cで書かれたコードをC#から呼び出すことができる。
- OSのAPIを呼び出すコードや、既存のC/C++のライブラリを再利用できる。
というものです。
C#でもOSのAPIを呼ぶことはできますが、構造体の定義やマーシャリングなど、いろいろと面倒くさいので、今回はC++でWin32APIを使ったメソッドを定義して、それをC#から呼び出すという方法で実装します。
大まかな流れ
- C++でWindowsのクリップボードから画像の情報を取り出す。
- C#で画像のビットマップを保存する領域を確保して、C++にアドレスを渡す。
- C++でC#から渡されたアドレスにビットマップをコピーする。
- C#でコピーされたビットマップをもとに、UnityのTexture2Dを作成する。
画像のデータをコピーするのにC++とC#の間を2往復していますが、後ほど述べるWindowsのBitmapの構造上、こうするしかありませんでした。
C++(NativePlugin)
まず、Visual StudioでDLL(ダイナミックリンクライブラリ)のプロジェクトを作成してください。
ウェブ上には様々な解説がありますが、最初はMicrosoftの解説を読んだほうがいいと思います。チュートリアル: 独自のダイナミック リンク ライブラリを作成して使用する (C++)
GetClipboardData
Win32APIではいくつかの段階を踏むことで、クリップボードのデータを取り出すことができます。例えば、画像であれば、次の通りです。
- クリップボードを開く。
- 欲しいフォーマットのデータがあるか確認する。
- フォーマットを指定して、データのハンドルを受け取る。
- ハンドルを欲しいデータへのポインタとしてキャストする。
- コピーしたり、処理したりする。
- クリップボードを閉じる。
Windowsにおいて標準でサポートされているフォーマットについては、こちらを参照してください。
Clipboard Formats - Win32 apps | Microsoft Docs
今回は、DIB(Device Independent Bitmap, デバイスに依存しないビットマップ)が欲しかったので、CF_DIBを指定しています。
bool isOpened = OpenClipboard(NULL); if (isOpened) { if (IsClipboardFormatAvailable(CF_DIB)) { HANDLE hClipboardData = GetClipboardData(CF_DIB); LPVOID clipboardDataPtr = GlobalLock(hClipboardData); BITMAPINFO* bitmapInfoPtr = static_cast<BITMAPINFO*>(clipboardDataPtr); // つづく } }BITMAPINFOというのは、ビットマップ画像について、様々な情報が詰まっている構造体です。ここから画像の大きさ、ビットの大きさ、ピクセルのデータを取り出します。
BITMAPINFO (wingdi.h) - Win32 apps | Microsoft Docs
BITMAPINFOHEADER (wingdi.h) - Win32 apps | Microsoft DocsBITMAPINFO* bitmapInfoPtr = static_cast<BITMAPINFO*>(clipboardDataPtr); int width = bitmapInfoPtr->bmiHeader.biWidth; int height = bitmapInfoPtr->bmiHeader.biHeight; int bitsPerPixel = bitmapInfoPtr->bmiHeader.biBitCount;ビットマップの取り出し
それでは、いよいよ画像のピクセルを取り出していきます。いきなりですが、こちらの画像をご覧ください。WindowsにおけるBitmap画像ファイルの構造を示したものです。
引用:Windows bitmap - Wikipedia
(「Wikipediaかよ…」と思った人もいると思いますが、ぶっちゃけこれがいちばんわかりやすかったです。)この画像を見ると、どうやらビットマップのヘッダーの直後に画像のピクセルが配置されているみたいなので、先ほど取り出したBITMAPINFOのbmiHeaderのポインタをシフトして、ピクセルデータまで移動します。
BITMAPINFOHEADERのポインタから、unsigned char型のポインタにキャストして、BITMAPINFOHEADERの大きさ分シフトします。
さらに、画像によっては画像に使う色を定義したカラーテーブルが含まれている場合もあるので、さらにその分シフトします。オプション的な機能らしいので、カラーテーブルをわざわざ持っているJPEGやPNGは少ないそうです。今回は、24ビットと32ビットのみの対応ですが、1, 4, 8ビットの画像には必ず入っているそうです。// BITMAPINFOHEADERのbiSizeは、BITMAPINFOHEADERの大きさを示します。 unsigned char* pixelData = (unsigned char*)(bitmapInfoPtr)+bitmapInfoPtr->bmiHeader.biSize; // カラーテーブルがあるときはその分シフトする if (bitmapInfoPtr->bmiHeader.biCompression == BI_BITFIELDS) { pixelData += bitmapInfoPtr->bmiHeader.biClrUsed; }ようやく、ビットマップまでたどり着いたので、いよいよC#から渡されたバッファにコピーしていきます。そこで、またまたこちらの画像をご覧ください。ピクセルデータの部分を拡大したものです。
Windows Bitmapのピクセルデータは、必ず1ライン(横幅, Width)が4バイトの倍数になるように確保されています。画像の大きさが足りない場合は、0で埋められます。
思わぬ落とし穴ですね。私はこれに気づくのに2日かかりました。よって、コピーするときは、これを考慮して1ラインずつコピーすることにします。
int bytesPerPixel = bitmapInfoPtr->bmiHeader.biBitCount / 8; // 1ピクセル当たりのバイト数 int bytesPerLine = width * bytePerPixel; // 1ライン当たりのバイト数 bytesPerLine += bytePerLine % 4 == 0 ? 0 : 4 - bytesPerLine % 4; // 4の倍数になるように増やしてあげる unsigned char* dst = buffer; // C#から渡されたバッファという想定 unsigned char* src = pixelData // Windows Bitmapのピクセルの先頭のポインタ // 1ラインずつコピーする for (int h = 0; h < height; h++) { memcpy( dst + (width * h * bytesPerPixel), src + (h * bytesPerLine), width * bytesPerPixel ); }コードは部分的にしか示しませんでしたが、これでC++側の実装は完了です。DLLをビルドしてUnityのプロジェクトにインポートしましょう。
C#(Unity)
NativePluginで定義したメソッドを静的クラスのメソッドとして定義します。DllImportAttributeでプラグインの名前を指定します。
DllImportAttribute クラス (System.Runtime.InteropServices) | Microsoft Docs
using System; using System.Runtime.InteropServices; using UnityEngine; public static class NativePlugin { // クリップボードに画像があるか [DllImport("Clipboard")] private static extern bool hasClipboardImage(); // クリップボードの画像の大きさと、1ピクセル当たりのビット数 [DllImport("Clipboard")] private static extern void getClipboardImageSize(ref int width, ref int height, ref int bitsPerPixel); // Bufferに画像のピクセルを書き込む [DllImport("Clipboard")] private static extern bool getClipboardImage(IntPtr buffer); }定義の仕方には、いろいろな流儀がありますが、私はNativePluginのメソッドはprivateにして、別にpublicなメソッドを定義して間接的に呼び出すようにしています。
バッファの確保
getClipboardImageSize(ref int width, ref int height, ref int bitsPerPixel)
とgetClipboardImage(IntPtr buffer)
というように、大きさの取得とデータの取得を分けているのは、C#側で事前に大きさを知ってバッファを確保するためです。
width, height, bitsPerPixelにref(参照渡しのキーワード)を付けているのは、NativePluginから一気に値を返してもらうためです。int width = 0; int height = 0; int bitsPerPixel = 0; getClipboardImageSize(ref width, ref height, ref bitsPerPixel);得た値を元に、バッファを確保します。今回は1ピクセルが24ビットと32ビットで、それぞれ1チャンネルが8ビット(1バイト)なので、byte配列をバッファとします。
また、C#では、ガベージコレクションによってメモリのアドレスが変わってしまうことがあるため、ハンドルを割り当てて、位置を固定してあげないといけません。「NativePluginにコピーしてもらうまで、じっとしてなさい!」と命令してあげます。コピーが終わったら、ちゃんと
Free()
で解放してあげます。GCHandle 構造体 (System.Runtime.InteropServices) | Microsoft Docs
// チャンネル数 int channel = bitsPerPixel / 8; // C#側の領域を用意する byte[] buffer = new byte[width * height * channel]; // GCによって移動しないように固定する。必ず開放する。 GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); // 確保したバッファのアドレス IntPtr bufferPtr = handle.AddrOfPinnedObject(); // クリップボードからコピーする bool successCopy = false; successCopy = getClipboardImage(bufferPtr); // 解放 handle.Free();これでようやくC#側に持ってくることができました。
Texture2Dの作成
あとは、クリップボードから持ってきたピクセルの配列を使って、Texture2Dを作成しましょう。
UnityのTexture2Dでは、ピクセルの配列から画像を作成することを想定していたのか、LoadRawTextureData
という素敵なメソッドが用意されているので、それを使います。TextureFormat.BGRA32
と指定すれば、そのまま流し込めます。残念ながらTextureFormat.BGR24
という形式はUnityにはないようなので、Color32[]
でアルファチャンネルの値を255で埋めてごまかします。Texture2D-LoadRawTextureData - Unity スクリプトリファレンス
Texture2D texture = new Texture2D(width, height, TextureFormat.BGRA32, false); // BGRA if(channel == 4) { texture.LoadRawTextureData(buffer); } // BGR else if (channel == 3) { Color32[] pixels = new Color32[width * height]; for (int i = 0; i < pixels.Length; i++) { pixels[i].b = buffer[channel * i]; pixels[i].g = buffer[channel * i + 1]; pixels[i].r = buffer[channel * i + 2]; pixels[i].a = (byte)255; } texture.SetPixels32(pixels); } texture.Apply();これでTexture2Dに変換できたので、Unityのオブジェクトにセットしてみましょう!
次のGIFは、クリップボードの画像をuGUIのImageに反映したものです。突っ込まれそうなポイント
なぜ、CF_DIBなのか。
Device Independent Bitmapという名前に惹かれました。CF_BITMAPでも同様のことができると思います。
使いどころは?
この機能を必要としている人たちもいます。UnityはVJなんかにも使われているので、クリップボードの画像を使うのもいいのではないでしょうか。Visual Effect Graphにも持っていけます。
アセットストアで売れる?
今回はすべてのフォーマットの画像をサポートしきれないなと思ったので、コードを公開しました。バグの修正やサポート形式の追加をお待ちしています。
まとめ
今回は、Windowsのクリップボードに保存されている画像をUnityで使うために、NativePluginでWin32APIを呼び出して、画像をコピーするという方法を取りました。
OSの中に保持されているデータを読み取っているので、これを応用すれば、ウィンドウをキャプチャしてUnityで再生するみたいなこともできそうです。記事中のコードは、説明のためにかなり内容を省略しているので、自分のプロジェクトに組み込みたい方は、GitHubのリポジトリを参照してください。
https://github.com/umetaman/UnityNativeClipboard近々、macOS向けの記事も書く予定です。こちらもかなりハマるポイントがありました。お楽しみに~
- 投稿日:2020-07-12T15:12:12+09:00
KlakNDIをAndroidに対応させる
はじめに
NDI®はNetwork Device Interfaceの略でNewTek社が開発したプロトコルです。簡単に言うとLanケーブルやWifiを使ってリアルタイムに映像の伝送ができます。NewTek社はSDKを公開してくれているので、開発者は自由にNDIを利用したソフトウェアを開発できます。
そのNDI SDKがAndroidに対応しました。どのバージョンから対応したのかは未確認ですが現状の最新版であるv4.5.3にはAndroid向けのSDKが存在します。ツイッターとか見てる限り最近のことのようです。個人的にAndroidとWindows/MacのUnity製アプリ間でリアルタイムに映像の送受信をしたかったのでこれを試してみました。
NDI SDKはUE4向けプラグインを公式でサポートして公開していますが、Unityに関しては特にサポートはありません。ですが、keijiroさんがUnity向けのプラグインKlakNDIを公開してくださっています。これを使いたいと思います。しかしKlakNDIに含まれているNDI SDKはv4.1でAndroid向けのSDKは含まれておらず非対応です。そこで自前でNDI SDK の Android向けのネイティブプラグインを組み込んで使えるようにしてみました。この記事はその記録です。
ソースコードはGitHubで公開しています。
https://github.com/tarakoKutibiru/Unity-NDI-Plugin-Android
直面している問題点
AndroidからUnity内のカメラ映像の送信はできましたが、受信はできませんでした。原因については調査中ですが、SDK自体が未対応の可能性が高そうです。
NDI SDK 4.5に添付された公式のドキュメントには、NDI SDKはiOSにおいて映像の送信には対応しているが受信は未対応であることが明言されています。それに対してAndroidには特に映像の受信が未対応であるという明確な記述はありません。しかし公式のフォーラムには
ARM CPUのエンコードには対応しているが、デコードには対応していないため、Android/iOSは映像の送信のみで、受信は対応していない。
という情報がありました。
NDI SDK本体のダウンロード
NDIの公式サイトでユーザー登録するとリンクが記述されたメールが届くのでそこからイントールします。Android,Windows,MAC,LINUXなどがあるので、必要に応じてインストールしてください。v4.5とv.4.1間で映像の伝送ができるかは未確認なので、とりあえず更新しておいたほうが無難だと思います。余談ですが本体のSDKにドキュメントやサンプルコードが付属してくるので、このへんを読むとすごく参考になります。特にC#の実装は参考になります。
NDILibDotNet2
っていうラッパークラス郡があるんですが、これは便利なのでUnityに取り込んでしまっても良いと思います。Windowsの場合はSDKが
C:\Program Files\NewTek\NDI 4 SDK (Android)\Lib\armeabi-v7a\libndi.so
にインストールされるのでこれを
KlakNDI\Packages\jp.keijiro.klak.ndi\Plugin\Android\libndi.so
のように配置します。
使いたいAndroid端末のABIが
armeabi-v7a
じゃない場合は対応するSDKを使って下さい。Config.csの修正
Config.csを修正してAndroidのSDKを読み込むようにします。
KlakNDI\Packages\jp.keijiro.klak.ndi\Runtime\Interop\Config.csnamespace Klak.Ndi.Interop { static class Config { #if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN public const string DllName = "Processing.NDI.Lib.x64"; #elif UNITY_EDITOR_OSX || UNITY_STANDALONE_OSX public const string DllName = "libndi.4"; #elif UNITY_EDITOR_LINUX || UNITY_STANDALONE_LINUX public const string DllName = "ndi"; #elif UNITY_ANDROID public const string DllName = "ndi"; #else public const string DllName = "__Internal"; #endif } }WifiManagerの作成
公式のドキュメントによるとAndroidでNDIを利用するにはNSDManagerのインスタンスを取得して保持する必要があるそうです。
Because Android handles discovery differently than other NDI platforms, some additional work is needed. The NDI library requires use of the “NsdManager” from Android and, unfortunately, there is no way for a third-party library to do this on its own. As long as an NDI sender, finder, or receiver is instantiated, an instance of the NsdManager will need to exist to ensure that Android’s Network Service Discovery Manager is running and available to NDI.
This is normally done by adding the following code to the beginning of your long running activities:
At some point before creating an NDI sender, finder, or receiver, instantiate the NsdManager:
You will also need to ensure that your application has configured to have the correct privileges required for this functionality to operate.DeepLを使った翻訳文は以下です。
Androidは他のNDIプラットフォームとは異なるディスカバリーを扱うため、いくつかの追加作業が必要になります。 NDI ライブラリは Android の「NsdManager」を使用する必要があり、残念ながら、サードパーティのライブラリが独自にこれを行う方法はありません。 NDI の送信者、検出者、または受信者がインスタンス化されている限り、Android の Network Service Discovery Manager が実行され、NDI で利用できるようにするために、NsdManager のインスタンスが存在する必要があります。
これは通常、以下のコードを長時間実行しているアクティビティの先頭に追加することで行います。
NDI のsender、finder、またはreceiverを作成する前のある時点で、NsdManager のインスタンスを作成します。
また、アプリケーションがこの機能を動作させるために必要な正しい権限を持つように構成されていることを確認する必要があります。ということなので
NsdManager
を取得して保持するWifiManager
クラスを適当な場所に作成します。WifiManager.csusing UnityEngine; using System.Collections.Generic; public class WifiManager { #if UNITY_ANDROID && !UNITY_EDITOR AndroidJavaObject nsdManager = null; #endif private static WifiManager instance = new WifiManager(); public static WifiManager GetInstance() { return instance; } private WifiManager() { } public void SetupNetwork() { // The NDI SDK for Android uses NsdManager to search for NDI video sources on the local network. // So we need to create and maintain an instance of NSDManager before performing Find, Send and Recv operations. #if UNITY_ANDROID && !UNITY_EDITOR using (AndroidJavaObject activity = new AndroidJavaClass("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity")) { using (AndroidJavaObject context = activity.Call<AndroidJavaObject>("getApplicationContext")) { using (AndroidJavaObject nsdManager = context.Call<AndroidJavaObject>("getSystemService", "servicediscovery")) { this.nsdManager = nsdManager; } } } #endif } }公式のドキュメントには適切な権限を取得しておく必要があるとのことでしたが、特にAndroidManifest.xmlにPermissionの追加をしなくても動作しました。おそらくUnityが勝手に解決してくれているのだと思います。
あとは適切なタイミングで
WifiManager.GetInstance().SetupNetwork();
を呼べばOKです。例えばKlakNDIの
SourceSelector.cs
のStartで呼べばOKです。SourceSelector.csusing UnityEngine; using UnityEngine.UI; using System.Collections.Generic; using System.Linq; using Klak.Ndi; public class SourceSelector : MonoBehaviour { [SerializeField] Dropdown _dropdown = null; NdiReceiver _receiver; List<string> _sourceNames; bool _disableCallback; // HACK: Assuming that the dropdown has more than // three child objects only while it's opened. bool IsOpened => _dropdown.transform.childCount > 3; void Start() { WifiManager.GetInstance().SetupNetwork(); _receiver = GetComponent<NdiReceiver>(); } void Update() { // Do nothing if the menu is opened. if (IsOpened) return; // NDI source name retrieval _sourceNames = NdiFinder.sourceNames.ToList(); // Currect selection var index = _sourceNames.IndexOf(_receiver.ndiName); // Append the current name to the list if it's not found. if (index < 0) { index = _sourceNames.Count; _sourceNames.Add(_receiver.ndiName); } // Disable the callback while updating the menu options. _disableCallback = true; // Menu option update _dropdown.ClearOptions(); _dropdown.AddOptions(_sourceNames); _dropdown.value = index; _dropdown.RefreshShownValue(); // Resume the callback. _disableCallback = false; } public void OnChangeValue(int value) { if (_disableCallback) return; _receiver.ndiName = _sourceNames[value]; } }PlayerSettingの変更
次にUnityの設定を変更していきます。
ビルド設定をAndroidに変更します。
するとエラーがでるのでPlayerSettingsを変更します。KlakNDIではUnityのカメラが描画している情報を取得するのに
AsyncGPUReadback
を使っていますが、これはOpenGLでは動かないのでGraphics APIsをVulkanに変更します。
未確認ですが
AsyncGPUReadback
をOpenGLでも使えるようにするプラグインが存在するようです。
https://github.com/Alabate/AsyncGPUReadbackPlugin結果
以上でKlakNDIをAndroidで利用できるようになったはずです
このGifはAndroid->Macですが、Android->Win10も動作確認できました。
使っているスマホはMotoG8という実売25000円くらいのローエンド端末です。あまり安定性はなくカクついています。映像の品質は端末やWifiルータの性能にも依存すると思うので、実用するなら機種の選定は必要な気がします。AndroidからPC2台に送信する実験もしてみましたが、さらにカクつくようになりました。
自分がやりたかったことは、PC上のUnityカメラの映像をAndroidに伝送して表示することだったので、今回紹介したNDI SDKでは実現できませんでした...ローカルネットワークでやりとりできれば十分だったので、NDIが使えると良かったんですけどね...WebRTCを勉強する必要があるかもですね...
- 投稿日:2020-07-12T00:17:23+09:00
Unity 学習 2日目
20.07.11
挑戦二日目
学習時間は、仕事から帰った後、子供が寝た後の夜中からなので、ちょっとずつしか進めませんが、
きろくとして残していきたいと思います。(完全に趣味です。)
今日は、ユニティちゃんが教える!初心者向けUnity講座
で学習を進めます!※動画が制作されたのが2年前なので一部機能しないようです。セクション2 ベーシックチュートリアルにて学習 前後編 計16分
(最新のUnityでは、ベーシックチュートリアルの提供がないようです。)
見ているだけなので、味気なくちょっとやりたかった。
①コンポーネントって大事だよ
②コンポーネントの調整ってこうやるんだよって
本動画でも言っておりましたが、プログラミングではなくパラメータをによって調整ができるインターフェイスってとても操作しやすい印象です。セッション3 基本のミニゲームをつくろ!
このセクションでは、別のプログラムが必要なようです。
Downloud先
最終更新が1年前なのでうまく起動するのでしょうか?こちらの内容を開くと再構築しますか?とし聞かれましたが、気にせず”YES”
再構築から動作確認まで結果30分ほどかかりました。(私のPCのスペックが悪いのかも)どうやら最新版までに少なくとも1回は更新されているためか、チュートリアル動画と更新内容が一部異なるようで、
操作するボールが玉乗りUnityちゃんに代わっておりより魅力的に!!眠たくなってきたので今日はここまで。
あとがき
アドバイスや参考になる動画URL、HPがあれば教えてください。
最後まで読んでくださりありがとうございます。
明日も頑張ります