299
225

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

生演奏×オーディオリアクティブ、人数無制限のVRライブシステムを作ってライブしたときのシステム構成と実装

Last updated at Posted at 2020-07-17

はじめに

phantom.gif

ぴぼ(@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のワールドに配信するという形になっています。
fuitch (3).png

解説の流れ

まずライブを実現する技術のコアとなる「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空間演出を再構成する

イベントビジュアライザ

イベントビジュアライザは、毎フレームアバターのモーション・空間の演出情報を下のような画像にします。

InkedOmnipresenceLiveテクスチャ - コピー_LI.jpg

水色で囲われている部分が演出情報、緑色で囲われている部分がモーション情報です。

イベントビジュアライザ・システム構成

  • 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

live2.png

音声をMax for Liveに送ります。
オーディオリアクティブトラック、生歌トラック、生ギタートラックにそれぞれ後述する紫色のMax for Liveプラグインを挿すことでそれぞれのパートの音量・ピッチを個別にOSCに変換します。

Max For Liveプラグイン

image.png

Max for Liveとは、Ableton Liveと連携可能な音声信号をすごく簡単に扱えるビジュアルプログラミング環境です。
Ableton Live Suiteの各トラックの音声の音量・ピッチ(音階)を算出してOSCで送信します。

Unity(演出信号ビジュアライザ)

audioEventVZoom.gif

音量・ピッチ・曲の展開に合わせた演出信号をテクスチャに変換します。
Max for Liveが出すOSC信号を受け取って画像化します。
OSCの受信には https://github.com/hecomi/uOSC を利用しています。

精度の要らない値となるべく精度が欲しい値で少し異なるアプローチを取ります。

精度の要らない値

音量の大小のようなざっくりとした値を色の明るさというざっくりとした値に変換します。
具体的には8bit以下(0~255)の精度でいい場合にはこの方法を用います。

下記の OSCVolumeAndPitchVisualizer.cs をアタッチしたGameObjectを、OSCを送信するトラック分用意しています。
image.png

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 × 4px8px × 8pxのようなブロックに1つの情報をまとめて書き込む
  • 色は使わずに明るさだけ、つまり0~255の8bitで値を表現する
8bitより大きい情報を2つの色に分割して埋め込む: 棄却案

8bitより大きい値を扱いたい場合は2ブロック以上を使って1つの値を表現することになります。が……
8bit=255段階の明るさすらもエンコードのためにノイズが混じった値になります。

16bit値を表現するため、はじめは2ブロックを使って1つめのブロックに前半8bit、2つめのブロックに後半8bit、のように分割することを試みました。
が、誤差が非常に大きくなってしまいました。

たとえば下記のような送りたい値 21,855 があったとします。
image.png

このとき、エンコードによってブロック1の値が3だけ変わってしまったとすると、結果は21,087になります。
ブロック1の値が85から82、わずか3/255が変わっただけですが、2ブロックの合計としては3*256=768変わることになります。
image.png

オブジェクトのx座標をこの2ブロックが保持する16bit=0~65535の値を用いて10000=1mとして表現した場合を考えると、エンコードによる1ブロックの明るさ3の誤差で簡単に約8cmズレてしまうことになります。

8bitより大きい情報を2つの色に分割して埋め込む: 採用案

そこで、2ブロックに交互に1bitずつ担当させるという方法を取ります。21,855はこのように表せます。
image.png
ブロック1の値が3変わって3から6になりましたが、値は21,885で、誤差が30で済みました。10000=1mなら3mmですね。
image.png

実装

実装はこんな感じです。(単なるビット演算です)
(本当は拡張子は.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体分の頂点情報です。
ピックアップ点.png

頂点情報を描画している1部分(左下緑色の四角領域)をクローズアップするとこのようになっています。
OmnipresenceLiveテクスチャCloseUp.png

1頂点につきx,y,z座標をそれぞれ4px × 4pxのブロック2つに書き込んでいます。

実装

実装はこんな感じです。

RealtimeVertexBaker16bitUnsignedInt.cs

public 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.c
	float 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.c

float 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.c
appdata 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;
}

エンコードノイズの弊害

エンコードによる劣化がなければ上記コードだけで綺麗に復元できます。
image.png
(左がテクスチャから復元したメッシュ、右が元のアバター)

が、実際にはエンコードによって情報が激しく劣化するので、エンコード済み映像をそのまま読むとこんな感じになります。
image.png

いくつかの頂点位置が吹き飛んだ値になってしまったためにその頂点を含むポリゴンが全体を覆い隠すサイズになってしまっています。
すべての頂点位置がおかしくなっているのではなく、中にはいくらか正常なポリゴンも存在します

エンコードノイズ対策

そのため、明らかに外れ値な値をジオメトリシェーダでフィルタリングします。(あまりいい解決方法ではないですが……)
下記の3条件でフィルタリングしました。

  1. ポリゴン内の辺の比が極端に異なる
  2. 頂点間の距離が離れすぎている
  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);
	}
}

フィルタリングした結果多少は原型をとどめた形になりました。
image.png

位置・姿勢の再構成

Skinned Mesh Rendererでないオブジェクトの位置・姿勢を再構成する際はこちらを用います。
Shader内で画像から読んだQuaternion値を用いてオブジェクトを回転させます。
オブジェクトのローカル座標系における原点から各頂点までのベクトルをそれぞれQuaternionで回転させることでオブジェクトの回転を表現します。

rotateWithQuaternion.c
float4 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ライブ
      • 制作チーム:
      • 2人は遠隔地の自宅からリアルタイムでセッション
      • 演奏しながらモーションキャプチャも行う
    • VRChatの複数インスタンスで同時にmemexのライブが行われる
      • インスタンスとは空間の単位。通常、ユーザーは同時に1つのインスタンスにしか参加できない。1インスタンスに入れる人数には制限(多くて60くらい)があるので、複数インスタンスで参加可能=人数制限がないということ。

「#解釈不一致」の解説トピック

  • 生歌とギターの生演奏による遠隔セッション
  • 楽曲を構成するトラック毎に反応するパラオーディオリアクティブな空間演出
  • 生演奏を含む音楽の特定のタイミングで発火する演出イベント管理
  • 遠隔モーションキャプチャ
  • ギターの生演奏とギターモーション収録の両立
  • ツイートのVR空間へのリアルタイム表示
  • 遠隔モーション収録時の返しモニタ
  • AWSによる専用映像ストリーミングサーバ構築
  • VRChatへのOmnipresence Liveリコンストラクタの実装
  • VRChat上での空間編集機能

システム構成図

fuitch (3).png

配信音声について

audio.png

遠隔地の演奏者と低遅延でセッションできるNETDUETTOを使って遠隔セッションした音声を配信しました。
ギタリスト側のDAWで伴奏を流しながらギターを演奏し、その音声をNETDUETTOでボーカル側に送ります。
ボーカル側はその伴奏とギターを聴きながら歌い、その音声をNETDUETTOでギタリスト側に送ります。
NETDUETTOはセッションのミックス結果を仮想オーディオ入力デバイスとして出力できる機能があり、これを用いて配信ソフトウェアであるOBS Studioに入力します。

オーディオリアクティブ演出

下の映像は、ドラムのキック2回→ドラムのスネア→ギターという順序で音が鳴った時の空間演出です。
audioreactive.gif

このような楽曲を構成する各パートの音に反応する演出を作るために、先述のイベントビジュアライザを用いて各パートの音量を色の明るさで表現して映像として配信しました。
audioEventVZoom.gif
左から順に下記トラックに反応しています。

  1. キック
  2. スネア
  3. ハット
  4. ギター(生演奏):ピッチ含む
  5. ベース:ピッチ含む
  6. 曲毎に任意に差し替える目立つ音
  7. 曲毎に任意に差し替える目立たない音
  8. ボーカル(生歌):ピッチ含む
  9. ハモリ・コーラス

なお、上から順に下記用途で4種類の出力をしています。

  1. 音量の値そのまま
  2. 音量の値を使い、減衰はゆるやかにしたもの
  3. 音量の値が累積されていくもの(音を鳴らす度に明るくなって最大になると黒からやりなおし)
  4. ピッチを取得しやすいよう輝度最大で固定して表示したもの

予め送信するトラック数と役割を定めておくことで、ワールド・エフェクトをデザインして頂いたMikipomさんとのやりとりが円滑になるようにしました。

前提

各パートに反応する演出を作るためには、各パートがバラバラになったパラデータが必要になります。
今回は自分達の楽曲のため各パートの音源を簡単に用意できましたが、そうでない場合もiZotope RX7といったツールを用いることである程度パートを分離した音声を用意できると思います。

ルーティング

audioOSC.png
NETDUETTOのVSTプラグインでは各演奏者の音声をバラバラに出力する機能があるため、これを用いて遠隔地のボーカルトラック単体を抜き出しています。

Ableton Live上での設定

Ableton Live Suiteの各トラックに、音は出力されない状態で各パートの音声を読み込みます。
音が出力されるのは、別途用意した各パートが混ざった伴奏音声、リアルタイムに入力する歌とギターのトラックのみです。
それぞれのトラックに音量をOSCに変換して送信するMax for Liveプラグインを挿すことで、それぞれのトラックから音量の値がOSCで送信されます。
image.png

演出イベントの管理

下の映像は、ライブの開始SEが終了して1曲目のイントロが始まり、ワールドそのものが出現する演出です。
phantom.gif

このように、オーディオリアクティブな要素とは別に、曲中の特定のタイミングに合わせて演出を行うために、Ableton Liveのタイムライン上に演出イベントのトリガーを配置しました。
トリガーはMIDI Note・MIDI Pitch BendをOSCに変換し、OSCをイベントビジュアライザで映像に載せることで演出イベントを発火させます。

制作から実際に再生されるまでの流れ

  1. デザイン担当のMikipomさんが演出要素をShader Onlyで作る
  2. デザイン担当のMikipomさんがUnityのTimelineで曲に合わせてシェーダパラメータを操作したデモを作る
  3. Timelineを元にどのタイミングでどのパラメータをどの値に動かしたいかという進行表を作る
    4. パラメータは0~1の範囲で表現できるようにしておく
  4. 動かしたいパラメータをそれぞれMIDI Note Num(ドレミファソラシド…)に割り当てる
  5. 進行表を元に筆者がAbleton Live上にパラメータを操作するためのMIDI Noteを配置していく
  6. それぞれのMIDI Note上でMIDI Pitch Bendを用いて0~1のパラメータを表現する
  7. OSCでMIDI Note NumとPitch Bendを送信する
  8. イベントビジュアライザを用いてパラメータをそれぞれ異なるピクセル上に色で表す
  9. ワールドに配置されたシェーダー側で特定のピクセルの色を読んで演出を再生する

進行表

いつ、どのマテリアルのどのパラメータをどこまで動かすかという進行表です。
image.png

演出イベント管理

動かしたいパラメータの数だけトラックを用意しました。
曲の特定のタイミングに合わせてMIDI Noteが配置されています。
image.png

演出制御用MIDI Note

あるオブジェクトの幅を表すパラメータを0から1まで動かすMIDI Noteです。
image.png

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に飛ばすことでアバターのモーションを表現しました。

画像左がギター側、画像右側が右手側のM5StickCです。
guitarTracking2.png

image.pngimage.png
画像左はローフレットを押さえて右下の方向を見ている筆者、画像右はハイフレットを押さえて左上の方向を見ている筆者です。

これは過去にアコースティックカバー生放送を行った時に制作したものです。
実際の動きは下記放送アーカイブからご覧いただけます。
【memex】アコースティック歌生放送! #めめなま 【3000人記念】 - YouTube

ギター側

フレット

ギターの指板における左手の位置と、ギターの姿勢をセンシングします。
左手の位置は超音波センサ(HC-SR04)、ギターの姿勢はM5StickC内蔵の加速度センサでセンシングしました。

超音波センサは超音波を発しそれが対象にぶつかってから帰ってくるまでの時間を音速で割ることで対象との距離を求めることができるセンサです。
これをギターのヘッド裏に取り付けることで左手の位置をざっくりと計測します。
実際にギターを弾いてみるとわかりますが、抑えるポジションによっては手首はネックの真裏の高さにないこともあり、正確にトラッキングすることはできません。左手が動いていることがわかるくらいではあります。
また、上記画像からわかるように筆者のアバターの手は抽象化されているので、指のあるアバターではさらに別の対策が必要になると思われます。

image.png
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の加速度センサを用いて、ギターの姿勢を特定の軸に限って表現します。
image.pngimage.png

加速度センサはスマートフォンの縦持ち、横持ちなどを判定する際に使われているセンサーで、デバイスの姿勢をある程度正確に計測することができます。(重力が影響しない軸の回転が計測できません)
ギターが地面に対してどの程度垂直かもしくは平行か、を角度で計測して、ギターの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.cs
public 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空間へのツイートのリアルタイム表示

配信映像に直接ツイート本文を載せ、リコンストラクタ側でその画像に透過処理をかけて空間に画像として表示しています。

VRChat_1920x1080_2020-05-30_14-52-00.522.png

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で表示しています。

tweet150.gif

出現アニメーションはリコンストラクタ側でつけることもできますが、リコンストラクタ側に状態を持たせたくなかったので、配信映像に載せる前の段階でアニメーションをつけました。
文字アニメーションにはText Juicerというプラグインを用いました。
GitHub - badawe/Text-Juicer: Simple tool to create awesome text animations https://github.com/badawe/Text-Juicer

返しモニタ

アバターの姿とUnityのコンソール出力を確認できる返しモニタを用意します。
主に下記2点の目的で返しモニタが必要でした。

  • 意図通りのモーションが反映されているか確認するため
  • ライブのMC時間にツイートを読み上げるため

Unityのマルチディスプレイ機能を用いて、配信用テクスチャを表示するウィンドウと別のウィンドウとして画面に表示します。
image.png

ライブ中自分達のアバターのモーションがどのようになっているか、またどんなツイートが観客側に表示されているかを一目で確認できるように、アバター表示に重ねてコンソール出力をCanvasに表示しています。

コンソール出力を表示する実装はこちらの記事を参考にしました。
【Unity】ゲーム画面にDebug.Logを出したい! - うら干物書き https://www.urablog.xyz/entry/2017/04/25/195351

返しモニタウィンドウをボーカルのPCにDiscordの画面共有で送ることで、同じ返しモニタを共有できるようにしています。

HLSサーバー

諸々の事情を考慮して映像はAWSで構築した専用のストリーミングサーバーから配信しました。
構成は下記の通りです。

aws.png

下記記事を参考にして殆どその通りの手順で進め、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.html

VRChat上で再生可能にする

  1. リコンストラクタのシェーダで表現されたライブに必要なオブジェクトをワールドに配置する
    • アーティストのモデル、ステージ、GPUパーティクルなど
  2. VRChat SDKのコンポーネントVRC_SyncVideoStreamを用いてCloudFrontで配信しているHLS映像のURLにアクセス
  3. 映像をワールド内に配置したカメラで撮影し1920 * 1080のRenderTextureに書き込む
  4. 各オブジェクトのマテリアルのテクスチャとして3のRenderTextureを指定する

VRChat上で空間編集を可能にする

下の映像のように、だいたいのオブジェクトを手で掴んで再配置できるようにしました。
同じインスタンスにいる観客同士のみに反映されます。
pickup.gif

  • オブジェクトにVRChat SDKのコンポーネントVRC_Pickupをつける

大変だったこと

解説は以上です。制作して大変だったことをメモしておきます。

シェーダーむずかしい

シェーダーでアニメーションさせるVATというものを使ったらモーションを画像で表現できるらしい、と聞いてシェーダーを調べ始めたものの、全然理解できなかった

  • これがなかったら1行も読めるようにならなかったと思う
  • リファレンスがわからない
    • 未だにどう調べたら欲しい情報にたどり着けるかわからない HLSL? CG?

つくっていることを秘密にしたい時、だれにも聞けない

12月くらいから制作していたのですが、人に聞けばおそらく一瞬で解決しそうなことで割と詰まってしまいました。
何を作っているかを秘密にしたいときって情報収集の仕方が難しいですよね……

エンコードこわい

  • H.264エンコードについて詳細に調べようと思ったけど挫折してしまった
    • 種類がありすぎる ドキュメントが長すぎる
    • ある程度ピクセルをブロックにまとめてやってるっぽいとか彩度は減衰しやすいっぽいという認識だけが残っています

UnityCaptureのピクセルズレ?

UnityCaptureでOBSに入力した画像は微妙にピクセルがズレていて、この用途では使えなかったのでビルドしてOBSのウィンドウキャプチャを使いました
トラブル起きたら一発アウトで再起動が必要になるので、本当に怖かったです……

映像ストリーミング、お金かかりすぎでは

おわりに

遠くない将来、好きな姿で、オーディオリアクティブな演出が行われるライブ会場で、リアルタイムに遠隔地のメンバーとセッションする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

  1. ライブ エンコーダの設定、ビットレート、解像度を選択する - YouTube ヘルプ https://support.google.com/youtube/answer/2853702?hl=ja

299
225
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
299
225

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?