20200712のC#に関する記事は7件です。

生演奏×オーディオリアクティブ、人数無制限の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で続きを読む

【C#】ディレクトリを再帰的に探索する【技術メモ】

指定したディレクトリ及びそのサブディレクトリ内のファイルに対して処理を実行したいときに使う。

/// <summary>
/// ディレクトリを再帰的に探索します。
/// </summary>
/// <param name="directoryName">探索するディレクトリのルート。</param>
private static void RecursivelySearchingDirectories(string directoryName)
{
    Directory
        .GetFiles(directoryName)
        .Where(/* 処理対象のファイルを抽出 */)
        .ToList()
        .ForEach(/* 実行したい処理 */);
    Directory
        .GetDirectories(directoryName)
        .ToList()
        .ForEach(RecursivelySearchingDirectories);
}
  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

[C#] Unsafeクラスの各メソッドを簡易解説(逆引き)

System.Runtime.CompilerServices.Unsafe クラスは一部で大変便利なのですが、ドキュメントを見ても動作が分かりにくいことが多いので、実際のILとC#へ逆コンパイルした結果を併記して簡易解説(メモ)します。

  • 本質的でない属性は除去しています。
    例: .custom instance void System.Runtime.Versioning.NonVersionableAttribute::.ctor() = ( 01 00 00 00 )
  • ニュアンスをつかみやすくするため、ILSpy version 6.0.0.5830-rc1 での逆コンパイル結果を併記しています。
  • 逆コンパイル結果がおかしい場合がありますが、ILをC#的に表現できないものがあるので気にしません。
  • ILコードをリポジトリから丸コピーしているので、ライセンスはThe MIT License (MIT)になります。

ILについて

  • .で始まるものはディレクティブ
  • &はそのまま「参照」。C++/CLIでいうインテリア参照
  • !!Tは、Tという名前のメソッドのジェネリック型引数です。
  • native intIntPtrです。
  • スタックベースの仮想マシンです。引数はスタックに積まれています。この記事で頻出の命令をメモしておきます。
    • 関数の引数は右側を先にスタックに積みます。
    • ldarg.Nで引数のN番目をスタックにロードします。
    • .maxstack N で使用する最大のスタック数を宣言します。
    • cpblk スタックから、コピー先、コピー元、バイト数をポップして、コピーする。コピー元とコピー先の領域とが重なっている場合,cpblkの動作は未規定。
  • 詳しくはWikipediaのリストPDFを見てください。
用語 この記事での使い方
ref IL的にはマネージドポインターですが、長いのでC#のrefとして表記します。
ポインター 「マネージドポインター」以外は、アンマネージドポインターとして用います。
オブジェクト参照 参照型のインスタンス
アライメントされた アーキテクチャに依存するアドレスに配置された

参照・ポインターの再解釈系

基本的にldarg.0してretする、つまり引数をそのまま戻り値の型として呼び出し元に返すだけ。

ジェネリック型のrefをvoidポインターとして解釈する

conv.u で参照をポインターに変換しているっぽい。

public unsafe static void* AsPointer<T>(ref T value)
{
    return Unsafe.AsPointer(ref value);
}
.method public hidebysig static void* AsPointer<T>(!!T& 'value') cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0
    conv.u
    ret
} // end of method Unsafe::AsPointer

型チェックをスキップして、object型を参照型のジェネリック型に再解釈する

純粋に再解釈するので誤ったキャストも許容される。
キャストの場合は、unbox.any !!T命令が使用される。

public static T As<T>(object o) where T : class
{
    return (T)o;
}
.method public hidebysig static !!T As<class T>(object o) cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0
    ret
} // end of method Unsafe::As

あるジェネリック型のrefを、別のジェネリック型のrefに再解釈する

C++でいうとTFrom& source = ...; return (TTo&)source; といったイメージ。

public static ref TTo As<TFrom, TTo>(ref TFrom source)
{
    return ref Unsafe.As<TFrom, TTo>(ref source);
}
.method public hidebysig static !!TTo& As<TFrom, TTo>(!!TFrom& source) cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0
    ret
} // end of method Unsafe::As

voidポインターをジェネリック型のrefに再解釈する

public unsafe static ref T AsRef<T>(void* source)
{
    return ref *(T*)source;
}
.method public hidebysig static !!T& AsRef<T>(void* source) cil managed aggressiveinlining
{
// For .NET Core the roundtrip via a local is no longer needed see:
// https://github.com/dotnet/coreclr/issues/13341
// and
// https://github.com/dotnet/coreclr/pull/11218
#ifdef netcoreapp
    .maxstack 1
    ldarg.0
    ret
#else
    .locals (int32&)
    .maxstack 1
    ldarg.0
    // Roundtrip via a local to avoid type mismatch on return that the JIT inliner chokes on.
    stloc.0
    ldloc.0
    ret
#endif
} // end of method Unsafe::AsRef

readonly refをジェネリック型のrefに再解釈する

public static ref T AsRef<T>(in T source)
{
    return ref source;
}

.param [1] で仮引数1(1オリジン)に属性を適用している。

.method public hidebysig static !!T& AsRef<T>(!!T& source) cil managed aggressiveinlining
{
    .param [1]
#ifdef netcoreapp
    .custom instance void [CORE_ASSEMBLY]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
#else
    .custom instance void System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = ( 01 00 00 00 )
#endif
    .maxstack 1
    ldarg.0
    ret
} // end of method Unsafe::AsRef

コピー/読み取り/書き込み系

アラインメントされたvoidポインターから、読んだ値を返す

Cでいうと*((T*)source);

public unsafe static T Read<T>(void* source)
{
    return *(T*)source;
}
.method public hidebysig static !!T Read<T>(void* source) cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0
    ldobj !!T
    ret
} // end of method Unsafe::Read

アラインメントされていないポインタから、読んだ値を返す

IA(x86, x64アーキテクチャ)では、恐らくUnalignedが付かない方を使用しても、(性能以外で)ペナルティはない。
逆コンパイル結果はアライメントされたものと同じだが、C#で表現できないからだろう。

public unsafe static T ReadUnaligned<T>(void* source)
{
    return *(T*)source;
}
.method public hidebysig static !!T ReadUnaligned<T>(void* source) cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0        
    unaligned. 0x1
    ldobj !!T
    ret
} // end of method Unsafe::ReadUnaligned

アラインメントされていないbyte型のマネージドポインターから、読んだ値を返す

逆コンパイル結果はアライメントされたものと同じだが、C#で表現できないからだろう。

public unsafe static T ReadUnaligned<T>(ref byte source)
{
    return *(T*)(&source);
}
.method public hidebysig static !!T ReadUnaligned<T>(uint8& source) cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0        
    unaligned. 0x1
    ldobj !!T
    ret
} // end of method Unsafe::ReadUnaligned

アラインメントされたvoidポインターへ値を書き込む

Cでいうと*((T*)) = value;

public unsafe static void Write<T>(void* destination, T value)
{
    *(T*)destination = value;
}
.method public hidebysig static void Write<T>(void* destination,
                                                !!T 'value') cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    stobj !!T
    ret
} // end of method Unsafe::Write

アラインメントされていないvoidポインターへ値を書き込む

Cでいうと*((__unaligned T*)) = value;

public unsafe static void WriteUnaligned<T>(void* destination, T value)
{
    *(T*)destination = value;
}
.method public hidebysig static void WriteUnaligned<T>(void* destination,
                                                !!T 'value') cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    unaligned. 0x01
    stobj !!T
    ret
} // end of method Unsafe::WriteUnaligned

アラインメントされていないbyte型のrefへ値を書き込む

public unsafe static void WriteUnaligned<T>(ref byte destination, T value)
{
    *(T*)(&destination) = value;
}
.method public hidebysig static void WriteUnaligned<T>(uint8& destination,
                                                !!T 'value') cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1        
    unaligned. 0x01
    stobj !!T
    ret
} // end of method Unsafe::WriteUnaligned

ジェネリック型のrefからvoidポインターへコピーする

public unsafe static void Copy<T>(void* destination, ref T source)
{
    *(T*)destination = source;
}
.method public hidebysig static void Copy<T>(void* destination,
                                            !!T& source) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    ldobj !!T
    stobj !!T
    ret
} // end of method Unsafe::Copy

voidポインターからジェネリック型のrefへコピーする

public unsafe static void Copy<T>(ref T destination, void* source)
{
    destination = *(T*)source;
}
.method public hidebysig static void Copy<T>(!!T& destination,
                                            void* source) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    ldobj !!T
    stobj !!T
    ret
} // end of method Unsafe::Copy

voidポインターから指定されたバイト数分、voidポインターへコピーする(Cで云うmemcpy)

public unsafe static void CopyBlock(void* destination, void* source, uint byteCount)
{
    // IL cpblk instruction
    Unsafe.CopyBlock(destination, source, byteCount);
}
.method public hidebysig static void CopyBlock(void* destination, void* source, uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    cpblk
    ret
} // end of method Unsafe::CopyBlock

byte型のrefから指定されたバイト数分、byte型のrefへコピーする(Cで云うmemcpy)

public static void CopyBlock(ref byte destination, ref byte source, uint byteCount)
{
    // IL cpblk instruction
    Unsafe.CopyBlock(ref destination, ref source, byteCount);
}
.method public hidebysig static void CopyBlock(uint8& destination, uint8& source, uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    cpblk
    ret
} // end of method Unsafe::CopyBlock

アラインメントされていないvoidポインターから指定されたバイト数分、voidポインターへコピーする

public unsafe static void CopyBlockUnaligned(void* destination, void* source, uint byteCount)
{
    // IL cpblk instruction
    Unsafe.CopyBlockUnaligned(destination, source, byteCount);
}
.method public hidebysig static void CopyBlockUnaligned(void* destination, void* source, uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    unaligned. 0x1
    cpblk
    ret
} // end of method Unsafe::CopyBlockUnaligned

アラインメントされていないbyte型のrefから指定されたバイト数分、voidポインターへコピーする

public static void CopyBlockUnaligned(ref byte destination, ref byte source, uint byteCount)
{
    // IL cpblk instruction
    Unsafe.CopyBlockUnaligned(ref destination, ref source, byteCount);
}
.method public hidebysig static void CopyBlockUnaligned(uint8& destination, uint8& source, uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    unaligned. 0x1
    cpblk
    ret
} // end of method Unsafe::CopyBlockUnaligned

voidポインターへ指定されたバイト数分、バイト値を書き込む(Cで云うmemset)

public unsafe static void InitBlock(void* startAddress, byte value, uint byteCount)
{
    // IL initblk instruction
    Unsafe.InitBlock(startAddress, value, byteCount);
}
.method public hidebysig static void InitBlock(void* startAddress, uint8 'value', uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    initblk
    ret
} // end of method Unsafe::InitBlock

byte型のrefへ指定されたバイト数分、バイト値を書き込む(Cで云うmemset)

public static void InitBlock(ref byte startAddress, byte value, uint byteCount)
{
    // IL initblk instruction
    Unsafe.InitBlock(ref startAddress, value, byteCount);
}
.method public hidebysig static void InitBlock(uint8& startAddress, uint8 'value', uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    initblk
    ret
} // end of method Unsafe::InitBlock

アライメントされていないvoidポインターへ指定されたバイト数分、バイト値を書き込む(Cで云うmemset)

public unsafe static void InitBlockUnaligned(void* startAddress, byte value, uint byteCount)
{
    // IL initblk instruction
    Unsafe.InitBlockUnaligned(startAddress, value, byteCount);
}
.method public hidebysig static void InitBlockUnaligned(void* startAddress, uint8 'value', uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    unaligned. 0x1
    initblk
    ret
} // end of method Unsafe::InitBlockUnaligned

アライメントされていないbyte型のrefへ指定されたバイト数分、バイト値を書き込む(Cで云うmemset)

public static void InitBlockUnaligned(ref byte startAddress, byte value, uint byteCount)
{
    // IL initblk instruction
    Unsafe.InitBlockUnaligned(ref startAddress, value, byteCount);
}
.method public hidebysig static void InitBlockUnaligned(uint8& startAddress, uint8 'value', uint32 byteCount) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    ldarg.2
    unaligned. 0x1
    initblk
    ret
} // end of method Unsafe::InitBlockUnaligned

アドレス演算系

ジェネリック型のrefを要素数分の加算する

C++で云う(T&)(((T*)&source)+elementOffset)

public static ref T Add<T>(ref T source, int elementOffset)
{
    return ref Unsafe.Add(ref source, elementOffset);
}
.method public hidebysig static !!T& Add<T>(!!T& source, int32 elementOffset) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    sizeof !!T
    conv.i
    mul
    add
    ret
} // end of method Unsafe::Add

ジェネリック型のrefを要素数分加算する

こちらはnative int版

public static ref T Add<T>(ref T source, IntPtr elementOffset)
{
    return ref Unsafe.Add(ref source, elementOffset);
}
.method public hidebysig static !!T& Add<T>(!!T& source, native int elementOffset) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    sizeof !!T
    mul
    add
    ret
} // end of method Unsafe::Add

voidポインターをジェネリック型として要素数分加算する

public unsafe static void* Add<T>(void* source, int elementOffset)
{
    return (byte*)source + (long)elementOffset * (long)sizeof(T);
}
.method public hidebysig static void* Add<T>(void* source, int32 elementOffset) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    sizeof !!T
    conv.i
    mul
    add
    ret
} // end of method Unsafe::Add

ジェネリック型のrefへ指定バイト数分加算する

public static ref T AddByteOffset<T>(ref T source, IntPtr byteOffset)
{
    return ref Unsafe.AddByteOffset(ref source, byteOffset);
}
.method public hidebysig static !!T& AddByteOffset<T>(!!T& source, native int byteOffset) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    add
    ret
} // end of method Unsafe::AddByteOffset

ジェネリック型のrefを要素数分減算する

ref *(((T*)&source) - elementOffset)

public static ref T Subtract<T>(ref T source, int elementOffset)
{
    return ref Unsafe.Subtract(ref source, elementOffset);
}
.method public hidebysig static !!T& Subtract<T>(!!T& source, int32 elementOffset) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    sizeof !!T
    conv.i
    mul
    sub
    ret
} // end of method Unsafe::Subtract

ジェネリック型のrefを要素数分加算する

こちらはnative int版

public static ref T Subtract<T>(ref T source, IntPtr elementOffset)
{
    return ref Unsafe.Subtract(ref source, elementOffset);
}
.method public hidebysig static !!T& Subtract<T>(!!T& source, native int elementOffset) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    sizeof !!T
    mul
    sub
    ret
} // end of method Unsafe::Subtract

voidポインターをジェネリック型として要素数分減算する

ref *(((T*)source) - elementOffset)

public unsafe static void* Subtract<T>(void* source, int elementOffset)
{
    return (byte*)source - (long)elementOffset * (long)sizeof(T);
}
.method public hidebysig static void* Subtract<T>(void* source, int32 elementOffset) cil managed aggressiveinlining
{
    .maxstack 3
    ldarg.0
    ldarg.1
    sizeof !!T
    conv.i
    mul
    sub
    ret
} // end of method Unsafe::Subtract

ジェネリック型のrefへ指定バイト数分減算する

ref *((T*)(((byte*)&source) - byteOffset))

public static ref T SubtractByteOffset<T>(ref T source, IntPtr byteOffset)
{
    return ref Unsafe.SubtractByteOffset(ref source, byteOffset);
}
.method public hidebysig static !!T& SubtractByteOffset<T>(!!T& source, native int byteOffset) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    sub
    ret
} // end of method Unsafe::SubtractByteOffset

refと同じ型の別のrefの差を求める

(byte*)(ref right) - (byte*)(ref left)

public static IntPtr ByteOffset<T>(ref T origin, ref T target)
{
    return Unsafe.ByteOffset(ref target, ref origin);
}
.method public hidebysig static native int ByteOffset<T>(!!T& origin, !!T& target) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.1
    ldarg.0
    sub
    ret
} // end of method Unsafe::ByteOffset

アドレス比較系

refが同じ値かどうかを比較する

public static bool AreSame<T>(ref T left, ref T right)
{
    return (ref left) == (ref right);
}
.method public hidebysig static bool AreSame<T>(!!T& left, !!T& right) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    ceq
    ret 
} // end of method Unsafe::AreSame

refが右辺より大きいか比較する

public static bool IsAddressGreaterThan<T>(ref T left, ref T right)
{
    return (ref left) > (ref right);
}
.method public hidebysig static bool IsAddressGreaterThan<T>(!!T& left, !!T& right) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    cgt.un
    ret 
} // end of method Unsafe::IsAddressGreaterThan

refが右辺より小さいか比較する

public static bool IsAddressLessThan<T>(ref T left, ref T right)
{
    return (ref left) < (ref right);
}
.method public hidebysig static bool IsAddressLessThan<T>(!!T& left, !!T& right) cil managed aggressiveinlining
{
    .maxstack 2
    ldarg.0
    ldarg.1
    clt.un
    ret 
} // end of method Unsafe::IsAddressLessThan

マーシャリングを考慮しないサイズを求める

unsafeコンテキストでsizeof()することと同じ。
Marshal.SizeOf()はマーシャリングを考慮する。

個人的にはwhere T : unmanagedをつけて欲しい。

public unsafe static int SizeOf<T>()
{
    return sizeof(T);
}
.method public hidebysig static int32 SizeOf<T>() cil managed aggressiveinlining
{
    .maxstack 1
    sizeof !!T
    ret
} // end of method Unsafe::SizeOf

.NET Core 3以降

box化された構造体(オブジェクト参照)の内部のrefを取得する

C++/CLIで云うcli::interior_ptrと等価。

public static ref T Unbox<T>(object box) where T : struct
{
    return ref (T)box;
}
.method public hidebysig static !!T& Unbox<valuetype .ctor ([CORE_ASSEMBLY]System.ValueType) T> (object 'box') cil managed aggressiveinlining
{
    .maxstack 1
    ldarg.0
    unbox !!T
    ret
} // end of method Unsafe::Unbox

.NET 5以降

初期化(0クリア)をスキップして変数宣言する

多分CLRの対応が必要

  .method public hidebysig static void SkipInit<T> ([out] !!T& 'value') cil managed aggressiveinlining
  {
        .maxstack 0
        ret
  } // end of method Unsafe::SkipInit

個人的に欲しいメソッド

組み合わせれば実現するが、できれば直接欲しいと思っているメソッドの一覧。

  • Cで云うoffsetof
  • Cで云うmemmove
    cpblkに領域が重なっている場合の保証がないので。
  • readonly refへの強制再解釈
  • 全体的にreadonly ref版のメソッド 必要無ければ、readonlyにしてほしいところだが、オーバーロードできないので多分厳しい。
  • アライメント判定
  • Overlaps判定(Span<T>にはあるけど。)
  • ジェネリック型T where T : unmanagedのマネージドポインターをポインターへの再解釈 public unsafe static T* ATypedsPointer<T>(ref T value) where T : unmanaged { return (T*)Unsafe.AsPointer(ref value); }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【C#】式木を使った動的なデリゲート生成

式木とは

C#の構文を動的に生成できる機能です。

メソッドでの生成とラムダ式での生成がサポートされています。

Expression<Func<int, int, int>> exp1 = (a, b) => a + b;

var paramA = Expression.Parameter(typeof(int), "a");
var paramB = Expression.Parameter(typeof(int), "b");
Expression<Func<int, int, int>> exp2 = Expression.Lambda<Func<int, int, int>>(
    Expression.Add(paramA, paramB), paramA, paramB);

// exp1とexp2は同等
var f1 = exp1.Compile();
var f2 = exp2.Compile();
Console.WriteLine(f1(3, 4)); // -> 7
Console.WriteLine(f2(3, 4)); // -> 7

式木から別の式木を作ってみる

T型の引数からIComparable<K>であるKを返すようなラムダ式を元にIComparer<T>を下記のように作れます。

例えば、コンストラクタに(string s) => -s.Lengthを渡すと文字列の長さの降順となるような結果が得られます。

public class ExpComparer<T, K> : IComparer<T> where K : IComparable<K>
{
    private class ParameterReplaceVisitor : ExpressionVisitor
    {
        private readonly ParameterExpression from;
        private readonly ParameterExpression to;
        public ParameterReplaceVisitor(ParameterExpression from, ParameterExpression to)
        {
            this.from = from;
            this.to = to;
        }
        protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : base.VisitParameter(node);
    }

    private readonly Comparison<T> func;
    public ExpComparer(Expression<Func<T, K>> expression)
    {
        var paramA = expression.Parameters[0];
        var paramB = Expression.Parameter(typeof(T));
        var exp2 = (Expression<Func<T, K>>)new ParameterReplaceVisitor(paramA, paramB).Visit(expression);
        var compExp = Expression.Lambda<Comparison<T>>(Expression.Call(
                expression.Body,
                typeof(K).GetMethod(nameof(IComparable<K>.CompareTo), new[] { typeof(K) }),
                exp2.Body),
                paramA, paramB);
        this.func = compExp.Compile();
    }
    public int Compare(T x, T y) => func(x, y);
    public override bool Equals(object obj) => obj != null && GetType() == obj.GetType();
    public override int GetHashCode() => GetType().GetHashCode();
}

以下、ポイントごとに解説します

paramA, paramB

Comparison<T>は引数を2つ持つので、それに対応するparamAparamBを用意します。

片方は元のParameterExpressionをそのまま流用でOKです。

ExpressionVisitor

引数の式木のパラメータを新たに生成したparamBで置き換える役割です。

Expression.Lambda<Comparison<T>>

ここでCompareToの呼び出しを構築します。

expressionをp => -pだったとき

// expressionを p => -pとする
(p1, p2) => -p1.CompareTo(-p2)

というようになります。

expression.Bodyexp2.Bodyの順番を間違えると

// expressionを p => -pとする
(p1, p2) => -p2.CompareTo(-p1)

になるので注意

Compile

CompileメソッドでLambdaExpressionをdelegateに変換します。

あとは普通のdelegateとして扱えます。

補足:ParameterExpressionについて

Expression<Func<string, int>> expression = a => a.Length;

var paramA = expression.Parameters[0];
var paramB = Expression.Parameter(typeof(string), "a");
var exp2 = (Expression<Func<string, int>>)new ParameterReplaceVisitor(paramA, paramB).Visit(expression);
var compExp = Expression.Lambda<Comparison<string>>(Expression.Call(
        expression.Body,
        typeof(int).GetMethod(nameof(IComparable<int>.CompareTo), new[] { typeof(int) }),
        exp2.Body),
        paramA, paramB);
var func = compExp.Compile();
Console.WriteLine(compExp); // -> (a,a) = a.Length.CompareTo(a.Length)

上記のようにexpressionのパラメータがaとなっている場合に、生成される式木が(a,a) = a.Length.CompareTo(a.Length)となりますが問題ありません。

ParameterExpressionNameプロパティが同一でもインスタンスが別(object.ReferenceEqualsでの比較がfalse)の場合は別の変数として扱われるためです。

逆にいうと、

Expression<Func<string, int>> expression = Expression.Lambda<Func<string, int>>(
    Expression.Property(Expression.Parameter(typeof(string), "a"), nameof(string.Length)), Expression.Parameter(typeof(string), "a"));

のような式木はa => a.Lengthとなりますが不正です。

Console.WriteLine(expression); // -> a => a.Length
Console.WriteLine(expression.Compile()("f42")); // Unhandled exception. System.InvalidOperationException: variable 'a' of type 'System.String' referenced from scope '', but it is not defined

ラムダの引数のaとa.Lengthのaが別の変数として扱われるためです。

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

共有フォルダを利用してnugetパッケージを管理する方法

? はじめに

3025413_m.jpg

通常、nuget パッケージを管理する場合には nuget.org を利用するか nuget サーバを作成して管理することが多いと思います。

ただ、小規模な企業や事務の方々にはなかなかにハードルが高いと思います。
これは技術的な話だけではなく、コンプライアンスやインフラ導入までの手続き的な意味も含めてです。

そこで今回はオープンな場での nuget パッケージの管理は禁止されており、なおかつ nuget サーバを独自で導入するのも難しい方向けのソリューションを紹介します。

? 注意事項

今回紹介する方法では共有フォルダにアクセスできる人間であれば誰でも自由に nuget パッケージを追加・編集・削除できてしまうので注意が必要です。
気休め程度ですが隠しフォルダにするなどの対応や、アクセス権限をいじれるのであれば適切なアクセス権限の付与を行うなどの対策が必要です。
あくまでも本来やらねばならないことをすっ飛ばして簡易的に nuget パッケージを管理したい場合の代替案だということは胸に深く刻んでおきましょう。

? How To

1. nuget パッケージを作成する

何はともあれ、管理したいライブラリがなければ話は進まないため、簡単なライブラリを用意していきます。
.NET Core を利用している場合は非常に簡単に dll プロジェクトから nuget パッケージを作成することができます。

a. dll プロジェクトを作成

まずは適当な位置にプロジェクトを作成します。
今回は F# でサンプルを作成していますが C# でもまったく同じ方法で実現可能です。

powershell
dotnet new classlib -lang="F#" -o="SampleProject"

すると、以下のようなプロジェクトが作成されると思います。
image.png

今回はライブラリの中身にあまり興味がないため、デフォルトのまま進めます。

b. nuget パッケージを作成

通常の dll を作成する場合は dotnet build コマンドを使って

powershell
dotnet build -c="Release"

とするだけで作成できます。

nuget パッケージを作成する場合には dotnet pack コマンドを使って

powershell
dotnet pack -c="Release"

とすれば簡単に作成することができます。
作成された nuget パッケージは .nupkg という拡張子で Relase フォルダに出力されます(デフォルトの場合)。
image.png

dotnet pack コマンドには他にもいろいろなオプション機能があるので 公式HP を参考にカスタマイズしてみてください。

2. nuget パッケージ管理用のフォルダを用意する

それでは作成した nuget パッケージを管理するためのフォルダを用意します。
と、いっても共有フォルダ上に1つフォルダを作成するだけなので、なんてことない作業です。

今回は 共有フォルダを用意するのが面倒だったので Dドライブ直下にフォルダを用意しています。

powershell
mkdir D:/nuget

フォルダ名は nuget としましたが、別になんでも問題ありません。

フォルダを作成したら、ココに nuget パッケージを移動します。
以下のようになっていれば OK です。
image.png

3. dotnet コマンドが管理用フォルダを参照できるようにする

現状のままでは dotnet コマンドが nuget パッケージ管理用フォルダを参照してくれていないので、参照してくれるように設定をしていきます。
設定には dotnet nuget add source を利用します。

powershell
# dotnet nuget add source <nuget パッケージ管理用フォルダのパス> -n <リソースの名称>
dotnet nuget add source "D:\\nuget" -n "Local Nuget Resources"

上記のコマンドを実行すると以下のようになると思います。
image.png

本当に追加されたかは dotnet nuget list source コマンドで確認することができます。
image.png

これで dotnet コマンドを利用して nuget 管理用フォルダで管理している nuget パッケージを追加することができるようになりました。

4. nuget 管理用フォルダ内のパッケージを利用してみる

それでは簡単なコンソールアプリを作成して、先ほど作ったサンプルライブラリのパッケージを利用してみましょう。
パッケージの追加は dotnet add package コマンドを利用します。

powershell
# コンソールアプリのプロジェクトを作成
dotnet new console -lang="F#" -o="SmpleConsole"
# カレントディレクトリを SampleProject にする
cd "./SampleProject"
# nuget パッケージをプロジェクトに追加する
dotnet add package SampleProject
# VS Code で開く
code .

上記のコマンドを順次実行するだけで準備は完了です。
Program.fs を以下のように修正して実行してみましょう。

Program.fs
// さきほど作成した SampleProject を open できるようになっている
open SampleProject

"Midoliy"
|> Say.hello
powershell
# dotnet run コマンドを利用して実行
dotnet run

実行結果は以下のようになると思います。
image.png

これで簡単に nuget パッケージが管理できるようになりました。

5. nuget パッケージがバージョンアップした場合に更新する

a. nuget パッケージのバージョンを上げる

それでは nuget パッケージのバージョンを上げてみましょう。
1. で作成した SampleProject のバージョンを 2.0.0 に上げてみます。
以下のように <Version></Version> 要素を追加します。

SampleProject.fsproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <Version>2.0.0</Version>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Library.fs" />
  </ItemGroup>

</Project>

あとは同じ手順で nuget パッケージを作成し、nuget パッケージ管理用フォルダに移動させます。
以下のような形になっていれば正解です。
image.png

b. コンソールアプリで参照している nuget パッケージのバージョンを更新する

次に 4. で作成したコンソールアプリの方を更新します。
更新方法は簡単で、もう一度 dotnet add package SampleProject を実行するだけです。

powershell
# カレントディレクトリを SampleProject にする
cd "./SampleProject"
# nuget パッケージをプロジェクトに追加する
dotnet add package SampleProject

すると nuget パッケージが最新のものに更新されます。
image.png

?おわりに

connect-316638.jpg

今回は dotnet コマンドを利用して、環境によらない方法での紹介としましたが Visual Studio を利用できる環境であればさらに簡単に済ませることができます。

こういったケースで困ることはあまりないと思いますが、何かしらの一助になれば幸いです。

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

.NET Coreの国際化リソースにデータベースを使う

.NET Coreの国際化リソースにデータベースを使う

前回に関連した内容です。

.NET Coreの国際化対応はデフォルトでは.resxを使いますが、それ以外の方法も使える仕組みになっています。

今回はデータベースのテーブルに保存した内容を用いて国際化対応をします。

その前にデフォルト実装の確認

デフォルトではLocalizationServiceCollectionExtensionsAddLocalizationメソッドを実行することで.resxファイルを用いた国際化が可能です。

国際化関係のクラスは以下のような階層になっています。

  • IStringLocalizer インターフェース
    • IStringLocalizer<T> インターフェース
      • StringLocalizer<T> クラス
    • ResourceManagerStringLocalizer クラス
  • IStringLocalizerFactory インターフェース
    • ResourceManagerStringLocalizerFactory クラス

StringLocalizer<T> はコンストラクタで IStringLocalizerFactory を受け取っており、ファクトリが生成したIStringLocalizer のインスタンスをフィールドに保持しています。
そしてIStringLocalizer インスタンスに処理を委譲する仕組みになっています。

なのでResourceManagerStringLocalizer, ResourceManagerStringLocalizerFactory に相当するクラスを用意することで、データベースから取得するローカライザを作ることができそうです。

本題

上記の内容を踏まえた上で データベースを用いて国際化対応をしてみます。ソースは以下の場所に配置しています。

データベースはPostgreSQL, データベースアクセスを簡略化するためにDapper を使っています。

また動作確認はWebアプリで行いました。
ただしビューにあたる部分は.cshtml ではなく生HTML(Vue.js)を使っています。
つまりサーバー側はWebAPIとして動かしています。

テーブルの用意

テーブルの形状(タテ持ち、ヨコ持ち)は任意ですが今回は以下のようにしました。

列名 主キー 説明
category varchar(20) 1 メッセージの種類
key_name varchar(20) 2 メッセージのキー
ja varchar(200) 日本語文字列
en varchar(200) 英語文字列

以下のデータを入れています。

-- カテゴリ:Item
insert into localization_resource values('Item', 'Item01', '田中', 'Tanaka');

-- カテゴリ:Message
insert into localization_resource values('Message', 'M0001', 'ようこそ{0}さん!', 'Hello. {0}!');
insert into localization_resource values('Message', 'M0002', 'おはようございます。', 'Good Morning.');

実装

SQLの検索結果の1レコードを表すクラスを用意

LocalizationRecord.cs
namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースで管理されている国際化リソースのレコード
    /// </summary>
    public class LocalizationRecord
    {
        public string Key { get; set; }

        public string Ja { get; set; }

        public string En { get; set; }
    }
}

データベースから取得したレコードを保持するクラス

DbLocalizedStringSource.cs
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースから取得した国際化リソースのソースを保持するクラス
    /// </summary>
    public class DbLocalizedStringSource
    {
        private readonly IDictionary<string, LocalizationRecord> _records;

        public DbLocalizedStringSource(IDictionary<string, LocalizationRecord> records)
        {
            _records = records;
        }

        public static DbLocalizedStringSource FromEnumerable(IEnumerable<LocalizationRecord> src)
        {
            IDictionary<string, LocalizationRecord> records = src.ToDictionary(x => x.Key);
            return new DbLocalizedStringSource(records);
        }

        public IEnumerable<string> GetAllKey()
        {
            return _records.Keys;
        }

        public string GetString(string name, CultureInfo currentUICulture)
        {
            if (_records.TryGetValue(name, out LocalizationRecord record))
            {
                switch (currentUICulture.Name)
                {
                    case "ja": return record.Ja;
                    case "en": return record.En;
                }
            }
            return null;
        }
    }
}

データベースからレコードを取得しDbLocalizedStringSource を返すクラス

DbLocalizedStringSourceProvider.cs
using Dapper;
using Npgsql;
using System;
using System.Collections.Generic;
using System.Data;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースから国際化リソースのソースを取得するクラス
    /// </summary>
    public class DbLocalizedStringSourceProvider
    {
        private const string connectionString = "Host=localhost;Database=test_db;Username=test_user;Password=test_user";

        public DbLocalizedStringSource GetLocalizedStrings(Type resourceSource)
        {
            using IDbConnection con = new NpgsqlConnection(connectionString);
            con.Open();
            using IDbTransaction tran = con.BeginTransaction();


            string sql = @"
SELECT
   key_name as Key
  ,ja       as Ja
  ,en       as En
FROM
   localization_resource
WHERE
   category = @category
ORDER BY
  key
";

            var param = new
            {
                category = resourceSource.Name
            };

            IEnumerable<LocalizationRecord> records = con.Query<LocalizationRecord>(sql, param, tran);
            return DbLocalizedStringSource.FromEnumerable(records);
        }
    }
}

IStringLocalizerの実装クラス
DbLocalizedStringSource に委譲しています。
(ResourceManagerStringLocalizer を参考)

DbStringLocalizer.cs
using Microsoft.Extensions.Localization;
using System;
using System.Collections.Generic;
using System.Globalization;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// データベースを使用したIStringLocalizerの実装
    /// </summary>
    public class DbStringLocalizer : IStringLocalizer
    {
        private readonly DbLocalizedStringSource _dbLocalizedStringSource;

        public DbStringLocalizer(DbLocalizedStringSource dbLocalizedStringSource)
        {
            _dbLocalizedStringSource = dbLocalizedStringSource;
        }

        /// <inheritdoc/>
        public LocalizedString this[string name]
        {
            get
            {
                if (name == null)
                {
                    throw new ArgumentNullException(nameof(name));
                }

                var value = GetString(name);

                return new LocalizedString(name, value ?? name, resourceNotFound: value == null, searchedLocation: null);
            }
        }

        /// <inheritdoc/>
        public LocalizedString this[string name, params object[] arguments]
        {
            get
            {
                if (name == null)
                {
                    throw new ArgumentNullException(nameof(name));
                }

                var format = GetString(name);
                var value = string.Format(format ?? name, arguments);

                return new LocalizedString(name, value, resourceNotFound: format == null, searchedLocation: null);
            }
        }

        private string GetString(string name, CultureInfo culture = null)
        {
            if (name == null)
            {
                throw new ArgumentNullException(nameof(name));
            }

            var keyCulture = culture ?? CultureInfo.CurrentUICulture;

            return _dbLocalizedStringSource.GetString(name, keyCulture);
        }

        public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
        {
            //includeParentCulturesを使ってない...

            IEnumerable<string> allKey = _dbLocalizedStringSource.GetAllKey();

            var culture = CultureInfo.CurrentUICulture;
            foreach (var key in allKey)
            {
                var value = GetString(key, culture);
                yield return new LocalizedString(key, value ?? key, resourceNotFound: value == null, searchedLocation: null);
            }
        }

        /// <summary>
        /// インターフェースのこのメソッドがObsoleteなので実装していません。
        /// </summary>
        /// <param name="culture"></param>
        /// <returns></returns>
        [Obsolete("This method is obsolete. Use `CurrentCulture` and `CurrentUICulture` instead.")]
        public IStringLocalizer WithCulture(CultureInfo culture)
        {
            throw new NotImplementedException("Not Implemented");
        }
    }
}

IStringLocalizerFactory の実装クラス
DbStringLocalizer の生成とキャッシュをしています。
(ResourceManagerStringLocalizerFactoryを参考)

DbStringLocalizerFactory.cs
using Microsoft.Extensions.Localization;
using System;
using System.Collections.Concurrent;

namespace DbStringLocalizerSample.Localizer
{
    /// <summary>
    /// DbStringLocalizerのファクトリ
    /// </summary>
    public class DbStringLocalizerFactory : IStringLocalizerFactory
    {
        private readonly ConcurrentDictionary<RuntimeTypeHandle, DbStringLocalizer> _localizerCache =
            new ConcurrentDictionary<RuntimeTypeHandle, DbStringLocalizer>();

        private readonly DbLocalizedStringSourceProvider _dbLocalizedStringSourceProvider;

        public DbStringLocalizerFactory(DbLocalizedStringSourceProvider dbLocalizedStringSourceProvider)
        {
            _dbLocalizedStringSourceProvider = dbLocalizedStringSourceProvider;
        }

        /// <inheritdoc/>
        public IStringLocalizer Create(string baseName, string location)
        {
            throw new NotImplementedException("Not Implemented");
        }

        /// <inheritdoc/>
        public IStringLocalizer Create(Type resourceSource)
        {
            return _localizerCache.GetOrAdd(resourceSource.TypeHandle, _ => CreateDbStringLocalizer(resourceSource));
        }

        private DbStringLocalizer CreateDbStringLocalizer(Type resourceSource)
        {
            DbLocalizedStringSource source = _dbLocalizedStringSourceProvider.GetLocalizedStrings(resourceSource);
            return new DbStringLocalizer(source);
        }
    }
}

Startup で使用するクラスを登録する。
AddLocalization より前にDbStringLocalizerFactoryをしておく

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    //...省略...

    //AddLocalizationより前にDbStringLocalizerFactoryを登録する
    services.AddTransient<DbLocalizedStringSourceProvider>();
    services.AddSingleton<IStringLocalizerFactory, DbStringLocalizerFactory>();
    services.AddLocalization();
}

使用方法

使用方法は.resx を使うときと同じです。

まずカテゴリ用に2つのクラスを用意します。

Item.cs
namespace DbStringLocalizerSample.Dummy
{
    public class Item
    {}
}
Message.cs
namespace DbStringLocalizerSample.Dummy
{
    public class Message
    {}
}

IStringLocalizer<T> をインジェクションするだけです。

[ApiController]
[Route("api/sandbox01")]
public class Sandbox01Controller : ControllerBase
{
    private readonly IStringLocalizer<Item> _itemLocalizer;
    private readonly IStringLocalizer<Message> _messageLocalizer;

    public Sandbox01Controller(IStringLocalizer<Item> itemLocalizer, IStringLocalizer<Message> messageLocalizer)
    {
        _itemLocalizer = itemLocalizer;
        _messageLocalizer = messageLocalizer;
    }

    [HttpGet("message01")]
    public IActionResult Message01()
    {
        string item = _itemLocalizer["Item01"];
        string mes = _messageLocalizer["M0001", item];
        return Content(mes);
    }

    [HttpGet("message02")]
    public IActionResult Message02()
    {
        string mes = _messageLocalizer["M0002"];
        return Content(mes);
    }
}

最後に

今回はデータベースを用いましたが、上記のポイントを押さえていれば、任意の方法を使って国際化対応ができそうです。

ただこの実装方法ではまだ少しだけ課題が残っています。

  • IStringLocalizer.WithCultureメソッドが実装されていない
  • IStringLocalizer.GetAllStrings(bool includeParentCultures)メソッドでincludeParentCultures が未使用
  • IStringLocalizer Create(string baseName, string location) を実装していないのでIViewLocalizerを使うことができない

これらを改善すればもう少し実用的なものになりそうです。

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