20200712のUnityに関する記事は6件です。

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

はじめに

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を元にどのタイミングでどのパラメータをどの値に動かしたいかという進行表を作る
    1. パラメータは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行も読めるようにならなかったと思う
- 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のウィンドウキャプチャを使いました
トラブル起きたら一発アウトで再起動が必要になるので、本当に怖かったです……

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

おわりに

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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

3D部分の解像度を下げつつもPostProcessingStackV2を使いたい

大元の画像はこちら。
3D描画に、PostProcessingStackV2でBloomとDoFがかかっています。
スクリーンショット 2020-07-12 21.53.41.png
白い部分が滲んでいる(Bloom)&遠景近景がぼやけている(DoF)ことが確認できます
この画像に、
【Unity】カメラ1つでUI解像度を維持し、3D解像度だけを下げる方法
を試してみた所、何やら違和感が。※解像度は半分で、低解像度を強調するためにFilterMode.Point。
スクリーンショット 2020-07-12 23.31.19.png
予定通り解像度は半分になった! ・・・けど、
あれ、PostProcessingStackV2の効果が消えてしまっている?
何故か?ということで、FrameDebuggerを確認した所、
モデルの描画については、指定したとおり、自分で用意したDisplayBufferに描画されている。
スクリーンショット 2020-07-12 22.09.47.png
モデルの描画は意図通りされている様子。
ただ、PostProcessingStackを行っている部分を確認すると…
スクリーンショット 2020-07-12 21.59.55.png
PostProcessingStackの最初の元画像を取得する部分がUnityWhiteテクスチャになってしまっている…!?
元画像が上手く取得できていないみたい。
いわゆるシェーダの

Properties
{
    _MainTex ("Texture", 2D) = "white" {}
}

の"white"を引っ張ってしまっている様子。

普段のPostProcessingStackなら、
スクリーンショット 2020-07-12 22.03.26.png
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用にカメラを分けてみることにしました。
順番としては下記の順序となります。

  1. メイン描画用カメラ
    1. SetTargetBuffers()で低解像度描画用RTに書き込むように設定
    2. AfterEverythingタイミングでCommandBufferでSetRenderTarget(-1)で描画先をフレームバッファにする
    3. AfterEverythingタイミングで低解像度描画用RTをCameraTargetにコピーする
  2. PPSV2用のカメラ
    1. メインカメラ描画の直後に描画するようにdepthを設定
    2. 念の為、位置や姿勢など、メイン描画カメラと全く同じ表示となるようにしておく
    3. CullingはPostProcessレイヤのみ表示するようにし、ClearFlagはDon't Clearにしてメイン描画をそのまま使う
    4. PostProcessLayerを付けておいてポストプロセスをかける

という事で「メイン描画は低解像度描画用RTにモデルなどを描画し、PPSV2用はTempBufferが用意されるだろう」という目論見で試してみた結果がこれ。
スクリーンショット 2020-07-12 23.00.15.png
DoFが効いていない…??
FlameDebuggerを確認すると、カメラを切り替えたタイミングでUpdateDepthTextureが走って、深度テクスチャが無効になっていることがわかる。
スクリーンショット 2020-07-12 23.16.08.png
PPSV2用カメラはDon't Clearにしてるのに…。何で深度をクリアしてしまうのか…。
まぁそれでも、カメラを2つにすることでカラー情報をTempBufferに渡すことができた。
という事は、後は深度バッファをPostProcessにわたすことができれば目的が達成できそう…!

なので、先程の処理に深度バッファの取得&コピーを追加してみる

  1. メイン描画用カメラ
    1. SetTargetBuffers()で低解像度描画用RTに書き込むように設定
    2. CommandBufferでAfterDepthTextureのタイミングで深度バッファをキャプチャしておく
    3. AfterEverythingタイミングでCommandBufferでSetRenderTarget(-1)で描画先をフレームバッファにする
    4. AfterEverythingタイミングで低解像度描画用RTをCameraTargetにコピーする
  2. PPSV2用のカメラ
    1. メインカメラ描画の直後に描画するようにdepthを設定
    2. 念の為、位置や姿勢など、メイン描画カメラと全く同じ表示となるようにしておく
    3. CullingはPostProcessレイヤのみ表示するようにし、ClearFlagはDon't Clearにしてメイン描画をそのまま使う
    4. AfterDepthTextureタイミングで、キャプチャ済み深度バッファをBuiltinRenderTextureType.Depthにコピーする
    5. PostProcessLayerを付けておいてポストプロセスをかける

できた画像がこちら。※わかりやすくするために、3D側解像度は半分にしています。
スクリーンショット 2020-07-12 23.21.27.png
ちゃんと3D解像度は半分になりつつ、PostProcessingStackV2の効果もかかっていることが確認できます。
FrameDebuggerも確認。
スクリーンショット 2020-07-12 23.24.07.png
PPSV2用カメラのUpdateDepthTextureの後処理で深度バッファをコピーできていることが見えます。


という事で、カメラ1つで何とかしようとしすぎた事で時間がかかってしまいましたが、なんとか目的達成できました。
同じ苦労をする人が少しでも減ると良いですね。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity + Epic Online Services で オンラインゲームを作ろう

今年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_chat

Unity + EOS で作ったチャットになります。
ゲームじゃないじゃん ゲームに必要な機能の確認レベルです。
通信部分は P2P で実装しておりますので、
通信データをメッセージから、キャラ座標などの情報に置き換えてゲームに組み込んでいただければです。

デモを動かす

Epic Games のアカウントを2つ作成

EOS のユーザー情報には Epic Games のアカウントを使います。
メールアドレスを2つ用意してアカウントを作成してください。
https://www.epicgames.com

EOS のクレデンシャル作成

デモを動かすためにはご自身で EOS のクレデンシャルを作成していただく必要があります。
Epic Games Japan が日本語の解説動画を上げてくれております。
12:15~23:30 がクレデンシャル作成の解説です。

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 を実行してください。

image.png
ポート番号の入力が求められます。
本デモでは 8765 をデフォルトにしてます。

image.png
アカウントをログインさせるページが表示されました。
先に作成した Epic Games のアカウントを2つログインさせます。

image.png
credential name は適当な名前を入力してください。
この名前はゲーム側で使用します。

image.png
二人分のログインが完了しました。
ツールは起動したままにしてください。

デモ実行

EXE を2つ起動します。

ログイン

image.png
それぞれ Dev User Name に先ほど EOS DevAuthTool で入力した credential name を入力し、Login を押してください。

ロビーの作成と入室

image.png
1. 左側で Create Lobby を押す
 しばらくすると画面が切り替わり Lobby Id 欄にランダムな文字列が生成されます。
 また、この時点で左側はロビーにログインしています。
2. 生成された文字列をコピー
3. 右側の Lobby Id 欄にペースト
4. 右側で Join Lobby を押す
両方ともに Chat と書かれたエリアが表示されたロビー入室完了です。

チャットで通信

image.png
メッセージを入力して 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)呼ばない

まとめ

重大なバグが放置されてるため、まだまだ本格利用は難しいですが、

  • 無料
  • サーバー不要
  • ソーシャル機能
  • (予定)ストア機能

と成熟すればかなり魅力的なサービスになると期待してます。
よければ皆さんも情報共有どんどんして、サービスを盛り上げていきましょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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を使用します。

ネイティブプラグイン - Unity マニュアル

簡単に説明すると、

  • C, C++, Objective-Cで書かれたコードをC#から呼び出すことができる。
  • OSのAPIを呼び出すコードや、既存のC/C++のライブラリを再利用できる。

というものです。

C#でもOSのAPIを呼ぶことはできますが、構造体の定義やマーシャリングなど、いろいろと面倒くさいので、今回はC++でWin32APIを使ったメソッドを定義して、それをC#から呼び出すという方法で実装します。

大まかな流れ

  1. C++でWindowsのクリップボードから画像の情報を取り出す。
  2. C#で画像のビットマップを保存する領域を確保して、C++にアドレスを渡す。
  3. C++でC#から渡されたアドレスにビットマップをコピーする。
  4. C#でコピーされたビットマップをもとに、UnityのTexture2Dを作成する。

画像のデータをコピーするのにC++とC#の間を2往復していますが、後ほど述べるWindowsのBitmapの構造上、こうするしかありませんでした。

C++(NativePlugin)

まず、Visual StudioでDLL(ダイナミックリンクライブラリ)のプロジェクトを作成してください。
ウェブ上には様々な解説がありますが、最初はMicrosoftの解説を読んだほうがいいと思います。

チュートリアル: 独自のダイナミック リンク ライブラリを作成して使用する (C++)

GetClipboardData

Win32APIではいくつかの段階を踏むことで、クリップボードのデータを取り出すことができます。例えば、画像であれば、次の通りです。

  1. クリップボードを開く。
  2. 欲しいフォーマットのデータがあるか確認する。
  3. フォーマットを指定して、データのハンドルを受け取る。
  4. ハンドルを欲しいデータへのポインタとしてキャストする。
  5. コピーしたり、処理したりする。
  6. クリップボードを閉じる。

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 Docs

BITMAPINFO* bitmapInfoPtr = static_cast<BITMAPINFO*>(clipboardDataPtr);

int width = bitmapInfoPtr->bmiHeader.biWidth;
int height = bitmapInfoPtr->bmiHeader.biHeight;
int bitsPerPixel = bitmapInfoPtr->bmiHeader.biBitCount;

ビットマップの取り出し

それでは、いよいよ画像のピクセルを取り出していきます。いきなりですが、こちらの画像をご覧ください。WindowsにおけるBitmap画像ファイルの構造を示したものです。

Windows bitmap

引用:Windows bitmap - Wikipedia
(「Wikipediaかよ…」と思った人もいると思いますが、ぶっちゃけこれがいちばんわかりやすかったです。)

この画像を見ると、どうやらビットマップのヘッダーの直後に画像のピクセルが配置されているみたいなので、先ほど取り出したBITMAPINFOのbmiHeaderのポインタをシフトして、ピクセルデータまで移動します。

bitmap_dst.png

BITMAPINFOHEADERのポインタから、unsigned char型のポインタにキャストして、BITMAPINFOHEADERの大きさ分シフトします。
さらに、画像によっては画像に使う色を定義したカラーテーブルが含まれている場合もあるので、さらにその分シフトします。オプション的な機能らしいので、カラーテーブルをわざわざ持っているJPEGやPNGは少ないそうです。今回は、24ビットと32ビットのみの対応ですが、1, 4, 8ビットの画像には必ず入っているそうです。

参考:BMPファイルのフォーマット

// BITMAPINFOHEADERのbiSizeは、BITMAPINFOHEADERの大きさを示します。
unsigned char* pixelData = (unsigned char*)(bitmapInfoPtr)+bitmapInfoPtr->bmiHeader.biSize;

// カラーテーブルがあるときはその分シフトする
if (bitmapInfoPtr->bmiHeader.biCompression == BI_BITFIELDS) {
    pixelData += bitmapInfoPtr->bmiHeader.biClrUsed;
}

ようやく、ビットマップまでたどり着いたので、いよいよC#から渡されたバッファにコピーしていきます。そこで、またまたこちらの画像をご覧ください。ピクセルデータの部分を拡大したものです。
padding.png

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に反映したものです。

unity_clipboard_result.gif

突っ込まれそうなポイント

なぜ、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向けの記事も書く予定です。こちらもかなりハマるポイントがありました。お楽しみに~

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

KlakNDIをAndroidに対応させる

Klak.gif

はじめに

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本体のダウンロード

new_tek.png
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.cs
namespace 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.cs
using 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.cs
using 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を変更します。

build_setting.png

KlakNDIではUnityのカメラが描画している情報を取得するのにAsyncGPUReadbackを使っていますが、これはOpenGLでは動かないのでGraphics APIsをVulkanに変更します。
build_setting.png

未確認ですがAsyncGPUReadbackをOpenGLでも使えるようにするプラグインが存在するようです。
https://github.com/Alabate/AsyncGPUReadbackPlugin

結果

以上でKlakNDIをAndroidで利用できるようになったはずです:tada:
Klak.gif

このGifはAndroid->Macですが、Android->Win10も動作確認できました。

使っているスマホはMotoG8という実売25000円くらいのローエンド端末です。あまり安定性はなくカクついています。映像の品質は端末やWifiルータの性能にも依存すると思うので、実用するなら機種の選定は必要な気がします。AndroidからPC2台に送信する実験もしてみましたが、さらにカクつくようになりました。

自分がやりたかったことは、PC上のUnityカメラの映像をAndroidに伝送して表示することだったので、今回紹介したNDI SDKでは実現できませんでした...ローカルネットワークでやりとりできれば十分だったので、NDIが使えると良かったんですけどね...WebRTCを勉強する必要があるかもですね...

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity 学習 2日目

20.07.11

挑戦二日目

学習時間は、仕事から帰った後、子供が寝た後の夜中からなので、ちょっとずつしか進めませんが、
きろくとして残していきたいと思います。(完全に趣味です。)
今日は、ユニティちゃんが教える!初心者向けUnity講座
で学習を進めます!※動画が制作されたのが2年前なので一部機能しないようです。

セクション2 ベーシックチュートリアルにて学習 前後編 計16分

(最新のUnityでは、ベーシックチュートリアルの提供がないようです。)
見ているだけなので、味気なくちょっとやりたかった。
①コンポーネントって大事だよ
②コンポーネントの調整ってこうやるんだよって
本動画でも言っておりましたが、プログラミングではなくパラメータをによって調整ができるインターフェイスってとても操作しやすい印象です。

スクリーンショット 2020-07-11 23.05.09.png

セッション3 基本のミニゲームをつくろ!

このセクションでは、別のプログラムが必要なようです。
Downloud先
最終更新が1年前なのでうまく起動するのでしょうか?

こちらの内容を開くと再構築しますか?とし聞かれましたが、気にせず”YES”
再構築から動作確認まで結果30分ほどかかりました。(私のPCのスペックが悪いのかも)

しっかり起動しました↓
スクリーンショット 2020-07-11 23.59.56.png

どうやら最新版までに少なくとも1回は更新されているためか、チュートリアル動画と更新内容が一部異なるようで、
操作するボールが玉乗りUnityちゃんに代わっておりより魅力的に!!

眠たくなってきたので今日はここまで。

あとがき

アドバイスや参考になる動画URL、HPがあれば教えてください。
最後まで読んでくださりありがとうございます。
明日も頑張ります

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む