- 投稿日:2020-07-12T23:48:44+09:00
生演奏×オーディオリアクティブ、人数無制限のVRライブシステムを作ってライブしたときのシステム構成と実装
はじめに
ぴぼ(@memex_pibo)と申します。
「memex」というVR空間で活動する2人組音楽ユニットをしています。
5月末ごろ、「Omnipresence Live」というVRライブシステムを作って「#解釈不一致」というタイトルのライブをしました。
生演奏に対してオーディオリアクティブな空間演出が行われて、かつ人数制限の無いライブという、類例の少ないVRライブになったと思います。
このエントリでは、その実装や運用を解説します。アーカイブ
「#解釈不一致」の空間アーカイブはVRChatのワールドとして公開してあります。
こちらから体験いただけます。所要時間は50分程度です。
https://vrchat.com/home/world/wrld_21a48553-fd25-40d0-8ff0-b4402b36172aまた、YouTube上に360°ステレオ映像のアーカイブを用意しております。
https://youtu.be/CIahS2Z1_Dsご覧いただいた方が理解しやすいと思いますので、是非一度観て頂きたいです。
システム図
「#解釈不一致」を行った際のシステム図です。
遠隔地にいるmemexの2人がネット越しに音声とモーションを共有し、それを一度映像に変換してからVRChatのワールドに配信するという形になっています。
解説の流れ
まずライブを実現する技術のコアとなる「Omnipresence Live」の実装について解説し、さらにその具体的な運用として「#解釈不一致」の事例を紹介します。
「Omnipresence Live」の解説トピック
- オーディオの音量をOSCに変換する
- MIDI信号をOSCに変換する
- OSCの値をUnityで受けてRenderTextureに書き込む
- 映像として情報を送信したい時の映像エンコード対策
- アバターの頂点アニメーションテクスチャをリアルタイムに映像化する
- 頂点情報が埋め込まれたテクスチャからアバターのモーションを再構成する
- Quaternionを用いてShaderのみでオブジェクトの回転を表現する
「#解釈不一致」の解説トピック
- 生歌とギターの生演奏による遠隔セッション
- 楽曲を構成するトラック毎に反応するパラオーディオリアクティブな空間演出
- 生演奏を含む音楽の特定のタイミングで発火する演出イベント管理
- 遠隔モーションキャプチャ
- ギターの生演奏とギターモーション収録の両立
- ツイートのVR空間へのリアルタイム表示
- 遠隔モーション収録時の返しモニタ
- AWSによる専用映像ストリーミングサーバ構築
- VRChatへのOmnipresence Liveリコンストラクタの実装
- VRChat上での空間編集機能
Omnipresence Live の実装
Omnipresence Live とは
VRライブを構成する要素であるアバターのモーション・演出・音声の情報を全て映像に変換して配信し、配信された映像を受け取ってVRライブを再構成するシステムです。
特徴
- 参加人数の制限が無い
- ただし観客が観客全員を視認できるわけではない
- 生歌・生演奏が可能
- 生モーション収録が可能
- パラオーディオリアクティブ(楽曲の各トラックに演出が反応する)な空間演出が可能
解決する課題
生演奏にタイミングが合ったオーディオリアクティブな空間演出のあるVRライブをする方法が(作らないと)ないこと
純粋に解決するなら音声とタイムコードを共有したモーション・演出を配信できるサーバーと対応したクライアントを作る必要がありそうです。手法
モーション・演出の情報を映像に変換することで音声とタイミングが一致した状態でモーション・演出を配信する
仕組み
次の2つのシステムに分けられます。
イベントビジュアライザ:アバターのモーションとVR空間演出のパラメータを毎フレーム画像として書き出す
リコンストラクタ:イベントビジュアライザが生成した画像からアバターの動きとVR空間演出を再構成するイベントビジュアライザ
イベントビジュアライザは、毎フレームアバターのモーション・空間の演出情報を下のような画像にします。
水色で囲われている部分が演出情報、緑色で囲われている部分がモーション情報です。
イベントビジュアライザ・システム構成
- Ableton Live Suite
- 概要
- DAW(作曲などに用いるソフト)
- 役割
- ライブのタイムライン管理
- Max for Liveへの橋渡し
- ライブ音声の処理
- Max for Live
- 概要
- Ableton Liveの各トラック上でMAX/MSP(ビジュアルプログラミング環境)を走らせられるプラグイン
- 役割
- ライブの音声の各トラック(歌、ギター、ドラムのキック、ドラムのスネアなど)の音量をOSCで送信する
- MIDI信号をOSCで送信する
- Unity(演出信号ビジュアライザ)
- 役割
- OSCで受け取った音量・MIDI信号を1920px × 1080pxのテクスチャに書き込む
- Unity(頂点ビジュアライザ)
- 役割
- VRMアバターの頂点の位置情報を一つずつ1920px × 1080pxのテクスチャに書き込む
- Unity(位置・姿勢ビジュアライザ)
- 役割
- 任意のオブジェクトのTransformを1920px × 1080pxのテクスチャに書き込む
Ableton Live Suite
音声をMax for Liveに送ります。
オーディオリアクティブトラック、生歌トラック、生ギタートラックにそれぞれ後述する紫色のMax for Liveプラグインを挿すことでそれぞれのパートの音量・ピッチを個別にOSCに変換します。Max For Liveプラグイン
Max for Liveとは、Ableton Liveと連携可能な音声信号をすごく簡単に扱えるビジュアルプログラミング環境です。
Ableton Live Suiteの各トラックの音声の音量・ピッチ(音階)を算出してOSCで送信します。Unity(演出信号ビジュアライザ)
音量・ピッチ・曲の展開に合わせた演出信号をテクスチャに変換します。
Max for Liveが出すOSC信号を受け取って画像化します。
OSCの受信には https://github.com/hecomi/uOSC を利用しています。精度の要らない値となるべく精度が欲しい値で少し異なるアプローチを取ります。
精度の要らない値
音量の大小のようなざっくりとした値を色の明るさというざっくりとした値に変換します。
具体的には8bit以下(0~255)の精度でいい場合にはこの方法を用います。下記の
OSCVolumeAndPitchVisualizer.cs
をアタッチしたGameObjectを、OSCを送信するトラック分用意しています。
OSCVolumeAndPitchVisualizer.cs
はトラック毎に定めたテクスチャの座標に、トラックの音声を元にした色を書き込みます。
- 音量 = 輝度(HSVのV)
- ピッチ = 色相(HSVのH)わざわざ色を書き込むのにスレッド数1のコンピュートシェーダを呼んでいますが
Texture2D.SetPixels()
でよいと思います。OSCVolumeAndPitchVisualizer.cs/// 簡略化しています using UnityEngine; using uOSC; public class OSCVolumeAndPitchVisualizer : MonoBehaviour { /// 0~1のfloat値を0~255のモノクロ値でピクセルに書き込むコンピュートシェーダ public ComputeShader NormalizedRGBValueTo64pxRGBBrightness; /// 書き込むピクセル位置 public int row, column; /// 書き込むテクスチャ [SerializeField] private RenderTexture output; /// OSCの受信サーバー [SerializeField] uOscServer server; /// 受け取るOSCアドレス [SerializeField] string address; /// volume: 0~1 /// pitch: 0=A(ラ)で0~11の整数 [SerializeField] float volume = 0, pitch = -2; /// hsv(色)値: 0=1 [SerializeField] float h, s = 1.0f, v; void Start() { server.onDataReceived.AddListener(OnDataReceived); } void OnDataReceived(Message message) { if (message.address == (address + "/volume")) { float.TryParse(message.values[0].GetString(), out volume); } else if (message.address == (address + "/pitch")) { // 0 = A, 1 = A# で0~11まで 検出できなかった場合-1が返ってくる float.TryParse(message.values[0].GetString(), out pitch); } } private void Update() { var dt = Time.deltaTime; /// 音量をそのまま明度に v = volume; /// ピッチを色相に(実際にはカクカク変わらないようにLerpAngleで補完処理をかけました) h = pitch / 12.0f; /// HSVカラーをRGBに変換 var rgb = Color.HSVToRGB(h, s, v * v); SetBlockRGB(row, column, rgb); } /// <summary> /// 指定したブロック(8px × 8px)を指定した色で塗る /// </summary> /// <param name="rowInMethod">ブロックの行</param> /// <param name="columnInMethod">ブロックの列</param> /// <param name="rgbInMethod">色</param> private void SetBlockRGB(int rowInMethod, int columnInMethod, Color rgbInMethod) { // 呼びたいカーネル(処理)を決める var kernel = NormalizedRGBValueTo64pxRGBBrightness.FindKernel("CSMain"); // 必要なデータやら参照やらを渡す NormalizedRGBValueTo64pxRGBBrightness.SetInt("row", rowInMethod); NormalizedRGBValueTo64pxRGBBrightness.SetInt("column", columnInMethod); NormalizedRGBValueTo64pxRGBBrightness.SetFloat("normalizedRed", rgbInMethod.r); NormalizedRGBValueTo64pxRGBBrightness.SetFloat("normalizedGreen", rgbInMethod.g); NormalizedRGBValueTo64pxRGBBrightness.SetFloat("normalizedBlue", rgbInMethod.b); NormalizedRGBValueTo64pxRGBBrightness.SetTexture(kernel, "OutPosition", output); // コンピュートシェーダを実行する NormalizedRGBValueTo64pxRGBBrightness.Dispatch(kernel, 1, 1, 1); } }Normalized8bitRGBValueTo64pxRGB.c// csから渡される値 RWTexture2D<float4> OutPosition; int row,column; float normalizedRed, normalizedGreen, normalizedBlue; [numthreads(1,1,1)] void CSMain (uint3 id : SV_DispatchThreadID) { for(uint x=0; x < 8; x++){ for(uint y=0; y < 8; y++){ OutPosition[uint2((row) * 8 + x, (column) * 8 + y)] = float4(normalizedRed, normalizedBlue, normalizedGreen, 1.0); } } }なるべく精度が欲しい値
情報量の限界
たとえばオブジェクトのx座標を8bit=0~255の値を用いて100=1mとして表現すると、2.56mの距離を1cmずつしか動かせないことになります。
ほかにも、25秒かけてゆっくりフェードさせたいといった場面で8bitしか表現できないと1秒につき値を約10回しか更新できず、カクカクした見た目になります。(実際にはエンコードでさらにカクカクになります)もっと精度が欲しい場合は、エンコードで情報が削がれないように気を使って値をピクセルに書き込む必要があります。
映像エンコード問題
パラメータを画像に変換・映像として配信し、受信した映像からパラメータを復元するにあたって問題となるのが映像のエンコードです。
人間が気付きにくい形で色のデータ量を圧縮することで配信・受信の通信を楽にしてくれる処理です。
が、これが正確な値を送りたい時の足枷となります。一般的に、デジタルな色は赤、緑、青の3色の度合い=RGB値で表現されています。
画像・映像データにおいては、1ピクセルは赤、緑、青それぞれ0~255の2^8=8bitの合計24bitで表すことが多いです。仮に1ピクセル24bitの色を1920px × 1080pxの画素数、30fpsの非圧縮映像にすると1秒当たりに必要なbit数は
24*1920*1080*30 / 1024^2 ≒ 1423Mbps
になります(値がでかすぎて計算が合ってるか不安になります)
が、一般的な映像ストリーミングサイト1において1920px × 1080pxの映像は3~6Mbpsでやりとりされています。
エンコーダが頑張って圧縮してくれているおかげですね。エンコード対策:ブロックと明るさ8bit
エンコードに対してある程度頑健な情報の送り方として、試行錯誤した結果下記の方法を取りました。(H.264エンコードの仕様をちゃんと読めばもっといい方法はあると思いますが……)
- なるべく4px × 4px
、8px × 8px
のようなブロックに1つの情報をまとめて書き込む
- 色は使わずに明るさだけ、つまり0~255の8bitで値を表現する8bitより大きい情報を2つの色に分割して埋め込む: 棄却案
8bitより大きい値を扱いたい場合は2ブロック以上を使って1つの値を表現することになります。が……
8bit=255段階の明るさすらもエンコードのためにノイズが混じった値になります。16bit値を表現するため、はじめは2ブロックを使って1つめのブロックに前半8bit、2つめのブロックに後半8bit、のように分割することを試みました。
が、誤差が非常に大きくなってしまいました。たとえば下記のような送りたい値
21,855
があったとします。
このとき、エンコードによってブロック1の値が
3
だけ変わってしまったとすると、結果は21,087
になります。
ブロック1の値が85
から82
、わずか3/255
が変わっただけですが、2ブロックの合計としては3*256=768
変わることになります。
オブジェクトのx座標をこの2ブロックが保持する16bit=0~65535の値を用いて10000=1mとして表現した場合を考えると、エンコードによる1ブロックの明るさ
3
の誤差で簡単に約8cmズレてしまうことになります。8bitより大きい情報を2つの色に分割して埋め込む: 採用案
そこで、2ブロックに交互に1bitずつ担当させるという方法を取ります。
21,855
はこのように表せます。
ブロック1の値が3
変わって3
から6
になりましたが、値は21,885
で、誤差が30
で済みました。10000=1mなら3mmですね。
実装
実装はこんな感じです。(単なるビット演算です)
(本当は拡張子は.compute ですがQiitaでハイライトされなくて読みにくいので.cとしています 以下同様)Writer8x16px16bitUnsignedInt.c// csから渡される値 RWTexture2D<float4> OutPosition; float val; int row; int column; float4 oddBitOfUintTo8bitBrightness(uint posMillimeter){ // 0bo-p-q-r-s-t-u-v- -> 0b00000000opqrstuv にする uint goal = 0; goal = (posMillimeter & 2) == 2 ? 1 : goal; goal = (posMillimeter & 8) == 8 ? goal | 2 : goal; goal = (posMillimeter & 32) == 32 ? goal | 4 : goal; goal = (posMillimeter & 128) == 128 ? goal | 8 : goal; goal = (posMillimeter & 512) == 512 ? goal | 16 : goal; goal = (posMillimeter & 2048) == 2048 ? goal | 32 : goal; goal = (posMillimeter & 8192) == 8192 ? goal | 64 : goal; goal = (posMillimeter & 32768) == 32768 ? goal | 128 : goal; return float4( goal & 0xff, goal & 0xff, goal & 0xff, 0) / 255.0; } float4 evenBitOfUintTo8bitBrightness(uint posMillimeter){ // 0b-o-p-q-r-s-t-u-v -> 0b00000000opqrstuv にする uint goal = 0; goal = (posMillimeter & 1) == 1 ? 1 : goal; goal = (posMillimeter & 4) == 4 ? goal | 2 : goal; goal = (posMillimeter & 16) == 16 ? goal | 4 : goal; goal = (posMillimeter & 64) == 64 ? goal | 8 : goal; goal = (posMillimeter & 256) == 256 ? goal | 16 : goal; goal = (posMillimeter & 1024) == 1024 ? goal | 32 : goal; goal = (posMillimeter & 4096) == 4096 ? goal | 64 : goal; goal = (posMillimeter & 16384) == 16384 ? goal | 128 : goal; return float4( goal & 0xff, goal & 0xff, goal & 0xff, 0) / 255.0; } [numthreads(1,1,1)] void CSMainFHD (uint3 id : SV_DispatchThreadID) { uint uintValue = val * 65535.0f; for(uint x=0; x < 8; x++){ for(uint y=0; y < 8; y++){ OutPosition[uint2((row) * 8 + x, (column) * 8 + y)] = oddBitOfUintTo8bitBrightness(uintValue); OutPosition[uint2((row + 1) * 8 + x, (column) * 8 + y)] = evenBitOfUintTo8bitBrightness(uintValue); } } }Unity(頂点ビジュアライザ)
頂点アニメーション
アバターの頂点群の位置情報を1頂点ずつ先述の方法でテクスチャに書き込みます。
Vertex Animation Textureと呼ばれる、アニメーションの各キーフレームにおける頂点位置をテクスチャに書き込んで再構成する手法を映像で行う形です。
実装にあたり、sugi-cho様のリポジトリ「Animation-Texture-Baker」を多大に参考にしました。
https://github.com/sugi-cho/Animation-Texture-Baker制約
Unityにおいて頂点の位置情報は32bit floatで表現されていますが、先述の理由からその精度を保証することは難しいため、範囲制限を設けることで16bit値で表現しています。
具体的には下記のような形で表現しました。
- 頂点位置は-3.2767m ~ 3.2767mの範囲のみを動けるものとする
- この範囲を超えた頂点は描画されなくなる
- 位置の値に3.2767mのオフセットを加え、unsigned intの16bit値0~65535
で位置を表現する出力イメージ
下図テクスチャの一番下の白い横長のかたまりがアバター1体分の頂点情報です。
頂点情報を描画している1部分(左下緑色の四角領域)をクローズアップするとこのようになっています。
1頂点につきx,y,z座標をそれぞれ4px × 4pxのブロック2つに書き込んでいます。
実装
実装はこんな感じです。
RealtimeVertexBaker16bitUnsignedInt.cspublic class RealtimeVertexBaker16bitUnsignedInt : MonoBehaviour { public ComputeShader infoTexGen; public Material material; // テクスチャのどの位置に書き込むかというオフセット public int columnOffset=0; private SkinnedMeshRenderer _skin; private int vertexCount; private const int TEX_WIDTH = 1920, TEX_HEIGHT = 1080; [SerializeField] private RenderTexture pRt; private Mesh mesh; private List<Vector3> posList; private ComputeBuffer posBuffer; private void Start() { // アバターのSkinned Mesh Rendererを取得する _skin = GetComponent<SkinnedMeshRenderer>(); vertexCount = _skin.sharedMesh.vertexCount; mesh = new Mesh(); // レンダーテクスチャを書き込み可能にする pRt.enableRandomWrite = true; } void Update() { // SkinnedMeshRenderから現在のフレームのmeshを作る _skin.BakeMesh(mesh); // コンピュートシェーダーに値を渡す入れ物をつくる // C言語の動的メモリ確保みたいな感じで、頂点数 * Vector3のサイズのバッファをつくる posBuffer = new ComputeBuffer(vertexCount, System.Runtime.InteropServices.Marshal.SizeOf(typeof(Vector3))); // meshの頂点位置情報をセットする posBuffer.SetData(mesh.vertices); var kernel = infoTexGen.FindKernel("CSMainFHD"); // 必要なデータや参照を渡す infoTexGen.SetInt("VertCount", vertexCount); infoTexGen.SetInt("ColumnOffset", columnOffset); infoTexGen.SetBuffer(kernel, "Pos", posBuffer); infoTexGen.SetTexture(kernel, "OutPosition", pRt); // コンピュートシェーダを実行する // 引数はスレッド数 // スレッド数は 頂点数 * 1 * 1 infoTexGen.Dispatch(kernel, vertexCount, 1, 1); posBuffer.Release(); } }VertexWriter16bitUnsignedIntFHD.c// csから渡される値 RWTexture2D<float4> OutPosition; StructuredBuffer<float3> Pos; int VertCount; int ColumnOffset; [numthreads(1,1,1)] void CSMainFHD (uint3 id : SV_DispatchThreadID) { // // id.xはそのまま頂点ID // row = id.x % (TEX_WIDTH / 4) // 4はcolumnあたりのx方向の画素数 // column = id.x / (TEX_WIDTH / 4) uint index = id.x; float3 pos = Pos[index]; int TEX_WIDTH = 1920; uint row = index % (TEX_WIDTH / 4); uint column = index / (TEX_WIDTH/ 4) + ColumnOffset; uint posXMillimeter = (pos.x + 3.2767f) * 10000.0f; uint posYMillimeter = (pos.y + 3.2767f) * 10000.0f; uint posZMillimeter = (pos.z + 3.2767f) * 10000.0f; //pos.x1 OutPosition[uint2(row * 4 + 0, column * 6 + 0)] = oddBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 0, column * 6 + 1)] = oddBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 1, column * 6 + 0)] = oddBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 1, column * 6 + 1)] = oddBitOfUintTo8bitBrightness(posXMillimeter); //pos.x2 OutPosition[uint2(row * 4 + 2, column * 6 + 0)] = evenBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 2, column * 6 + 1)] = evenBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 3, column * 6 + 0)] = evenBitOfUintTo8bitBrightness(posXMillimeter); OutPosition[uint2(row * 4 + 3, column * 6 + 1)] = evenBitOfUintTo8bitBrightness(posXMillimeter); //pos.y1 //省略 //pos.y2 //省略 //pos.z1 //省略 //pos.z2 //省略 }Unity(位置・姿勢ビジュアライザ)
Skinned Mesh Rendererがついたオブジェクトは頂点位置を書き込みましたが、そうでない形が変わらないオブジェクトは単にオブジェクトのPositionとRotationを書き込みます。
RotationはQuaternionのx,y,z,wがそれぞれ-1 ~ 1
の範囲で表現されているので、0 ~ 1
の範囲に正規化して8bit値で書き込みます。リコンストラクタ
イベントビジュアライザが生成した画像から演出・モーションを再構成します。
リコンストラクタをVRChatに実装するため全てシェーダーで記述していますが、特別GPUに頼らなくてもいい場面に関してはC#でRenderTexture.ReadPixels()
を使う方が素直だと思います。演出は任意のシェーダーパラメータを操作する形で表現します。
モーションは頂点情報をテクスチャに書き込んだモデルと同じモデルにアタッチしたシェーダーの頂点シェーダーで画像から頂点位置を復元します。演出の再構成
画像の特定のピクセルの色を呼んでシェーダーのパラメータを任意に操作するものです。
エンコード済みの動画から色を読むときは8px × 8px の四角形領域の外枠を捨てて 6px × 6pxの中心を読むと誤差が気持ち減ったような気がするのでそうしています。
ReadBlock.cfloat texelSizeX = (1.0 / 1920.0); float texelSizeY = (1.0 / 1080.0); float4 color = float4(1,1,1,1); // value1 rowとcolumnで指定された8*8正方形の1ブロックを読む // 中心6*6を読んでとりあえず合計する for(uint x = 0; x < 6; x++){ for(uint y = 0; y < 6; y++){ float2 address = float2( // 8pxで1ブロックなのでrow*8, ブロック内の外周は捨てる方が精度が高いと思われる ( (_row) * 8 + 1 + x ) * texelSizeX, ( (_column) * 8 + 1 + y ) * texelSizeY ); color += tex2Dlod(_valueTex, float4(address.x, address.y, 0, 0)); } } // 合計を36で割って平均化する color = color / 36.0;2ブロックから16bit値を読みたい時は下記のようなコードを用いました(単にビット演算です)
Unpack.cfloat unpackUintFromDoubleFloat4(float4 oneSecond, float4 twoSecond){ // 8bitの値 oneSecond = 0bxxxxxxxx と twoSecond = 0byyyyyyyy を合わせて 16bit goal = 0bxyxyxyxyxyxyxyxy にする uint4 oS = uint4(oneSecond * 255.0 + 0.5); uint4 tS = uint4(twoSecond * 255.0 + 0.5); uint firstGoal = (oS.x & 1) == 1 ? 2 : 0; firstGoal = (oS.x & 2) == 2 ? firstGoal | 8 : firstGoal; firstGoal = (oS.x & 4) == 4 ? firstGoal | 32 : firstGoal; firstGoal = (oS.x & 8) == 8 ? firstGoal | 128 : firstGoal; firstGoal = (oS.x & 16) == 16 ? firstGoal | 512 : firstGoal; firstGoal = (oS.x & 32) == 32 ? firstGoal | 2048 : firstGoal; firstGoal = (oS.x & 64) == 64 ? firstGoal | 8192 : firstGoal; firstGoal = (oS.x & 128) == 128 ? firstGoal | 32768 : firstGoal; uint secondGoal = (tS.x & 1) == 1 ? 1 : 0; secondGoal = (tS.x & 2) == 2 ? secondGoal | 4 : secondGoal; secondGoal = (tS.x & 4) == 4 ? secondGoal | 16 : secondGoal; secondGoal = (tS.x & 8) == 8 ? secondGoal | 64 : secondGoal; secondGoal = (tS.x & 16) == 16 ? secondGoal | 256 : secondGoal; secondGoal = (tS.x & 32) == 32 ? secondGoal | 1024 : secondGoal; secondGoal = (tS.x & 64) == 64 ? secondGoal | 4096 : secondGoal; secondGoal = (tS.x & 128) == 128 ? secondGoal | 16384 : secondGoal; uint goal = firstGoal | secondGoal; float value = goal; return value; }読んだ色の色相値を使ってオブジェクトを円周上に動かす演出例です。
vert.c// vertシェーダ内 //------RGB to HSV ------- float3 hsv = rgb2hsv(color); //------------------------ float rad = radians(hsv.x*360.0); v.vertex.x += cos(rad); v.vertex.y += sin(rad); //------------------------ v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o;モーションの再構成
各頂点の位置をテクスチャから読んだ位置に差し替えます
vert.cappdata vert (appdata v, uint vid : SV_VertexID) { // テクスチャから値を取ってくるところは演出と同じため省略 // 16bitの0~65536の値を-3.2767m ~ 3.2767mの元の位置情報に直す float posX = unpackUintFromDoubleFloat4(oneSecondPosX, twoSecondPosX) / 10000.0f - 3.2767f; float posY = unpackUintFromDoubleFloat4(oneSecondPosY, twoSecondPosY) / 10000.0f - 3.2767f; float posZ = unpackUintFromDoubleFloat4(oneSecondPosZ, twoSecondPosZ) / 10000.0f - 3.2767; float3 pos = float3(posX, posY, posZ); appdata o; o.vertex = v.vertex; // 頂点位置をテクスチャから読み込んだものに差し替える o.vertex.xyz = pos; o.uv = v.uv; return o; }エンコードノイズの弊害
エンコードによる劣化がなければ上記コードだけで綺麗に復元できます。
(左がテクスチャから復元したメッシュ、右が元のアバター)が、実際にはエンコードによって情報が激しく劣化するので、エンコード済み映像をそのまま読むとこんな感じになります。
いくつかの頂点位置が吹き飛んだ値になってしまったためにその頂点を含むポリゴンが全体を覆い隠すサイズになってしまっています。
すべての頂点位置がおかしくなっているのではなく、中にはいくらか正常なポリゴンも存在しますエンコードノイズ対策
そのため、明らかに外れ値な値をジオメトリシェーダでフィルタリングします。(あまりいい解決方法ではないですが……)
下記の3条件でフィルタリングしました。
- ポリゴン内の辺の比が極端に異なる
- 頂点間の距離が離れすぎている
- 頂点が使わなさそうな位置にいる
実際のコードが以下になります(値を直書きするのはやめましょう……)
Filter.c[maxvertexcount(3)] void geom(triangle appdata IN[3], inout TriangleStream<g2f> triStream) { // このポリゴンを省くか?のフラグ bool isBug = false; // 今見ている三角ポリゴンの各辺の長さを取得します float sideLength0to1 = length(IN[0].vertex - IN[1].vertex); float sideLength1to2 = length(IN[1].vertex - IN[2].vertex); float sideLength2to0 = length(IN[2].vertex - IN[0].vertex); float rateThreshold = 5.0; // フィルター:辺の比がおかしかったら消す isBug = sideLength0to1 > sideLength1to2 * rateThreshold || sideLength1to2 > sideLength2to0 * rateThreshold || sideLength2to0 > sideLength0to1 * rateThreshold ? true : isBug; // フィルター:ある頂点間の距離がx[m]以上あったら float threshold = 0.4; isBug = sideLength0to1 > threshold || sideLength1to2 > threshold || sideLength2to0 > threshold ? true : isBug; // フィルター:頂点が範囲外なら for (int i = 0; i < 3; i++) { appdata v = IN[i]; isBug = v.vertex.x > 1.0 || v.vertex.y > 2.0 || v.vertex.z > 1.0 || v.vertex.x < -1.0 || v.vertex.y < -1.0 || v.vertex.z < -1.0 ? true : isBug; } [unroll] for (int i = 0; i < 3; i++) { // 頂点シェーダからもらった3頂点それぞれを射影変換して通常のレンダリングと同様にポリゴン位置を決める appdata v = IN[i]; g2f o; // isBugフラグがあれば頂点位置を原点に飛ばす(discardでよさそう) o.vertex = isBug ? float4(0,0,0,0) : UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.normal = UnityObjectToWorldNormal(normal); triStream.Append(o); } }位置・姿勢の再構成
Skinned Mesh Rendererでないオブジェクトの位置・姿勢を再構成する際はこちらを用います。
Shader内で画像から読んだQuaternion値を用いてオブジェクトを回転させます。
オブジェクトのローカル座標系における原点から各頂点までのベクトルをそれぞれQuaternionで回転させることでオブジェクトの回転を表現します。rotateWithQuaternion.cfloat4 quatenionAxQuaternionB(float4 qa, float4 qb) { return float4( qa.w * qb.x + qa.x * qb.w + qa.y * qb.z - qa.z * qb.y, qa.w * qb.y - qa.x * qb.z + qa.y * qb.w + qa.z * qb.x, qa.w * qb.z + qa.x * qb.y - qa.y * qb.x + qa.z * qb.w, qa.w * qb.w - qa.x * qb.x - qa.y * qb.y - qa.z * qb.z ); } v2f vert (appdata v, uint vid : SV_VertexID) { // -----------省略------------- float4 quaternion = float4(qx,qy,qz, qw); float4 conjugateQ = float4(-qx, -qy, -qz, qw); // 共役 float4 vertAsQ = float4(v.vertex.x, v.vertex.y, v.vertex.z, 0); float4 rotatedPos = quatenionAxQuaternionB(quatenionAxQuaternionB(quaternion, vertAsQ), conjugateQ); v2f o; o.vertex = UnityObjectToClipPos(rotatedPos); o.uv = v.uv; return o; }#解釈不一致 の運用
ここからは、Omnipresence Liveを用いてどのようにライブ「#解釈不一致」を運用したかについて記載します。
- 概要
- 筆者の所属する2人組アーティスト「memex」のVRライブ
- 制作チーム:
- memex
- アラン(@memex_aran):ボーカル
- ぴぼ(筆者)(@memex_pibo):ギター
- ワールド・エフェクトデザイン
- Mikipom(@cakemas0227)
- 2人は遠隔地の自宅からリアルタイムでセッション
- 演奏しながらモーションキャプチャも行う
- VRChatの複数インスタンスで同時にmemexのライブが行われる
- インスタンスとは空間の単位。通常、ユーザーは同時に1つのインスタンスにしか参加できない。1インスタンスに入れる人数には制限(多くて60くらい)があるので、複数インスタンスで参加可能=人数制限がないということ。
「#解釈不一致」の解説トピック
- 生歌とギターの生演奏による遠隔セッション
- 楽曲を構成するトラック毎に反応するパラオーディオリアクティブな空間演出
- 生演奏を含む音楽の特定のタイミングで発火する演出イベント管理
- 遠隔モーションキャプチャ
- ギターの生演奏とギターモーション収録の両立
- ツイートのVR空間へのリアルタイム表示
- 遠隔モーション収録時の返しモニタ
- AWSによる専用映像ストリーミングサーバ構築
- VRChatへのOmnipresence Liveリコンストラクタの実装
- VRChat上での空間編集機能
システム構成図
配信音声について
遠隔地の演奏者と低遅延でセッションできるNETDUETTOを使って遠隔セッションした音声を配信しました。
ギタリスト側のDAWで伴奏を流しながらギターを演奏し、その音声をNETDUETTOでボーカル側に送ります。
ボーカル側はその伴奏とギターを聴きながら歌い、その音声をNETDUETTOでギタリスト側に送ります。
NETDUETTOはセッションのミックス結果を仮想オーディオ入力デバイスとして出力できる機能があり、これを用いて配信ソフトウェアであるOBS Studioに入力します。オーディオリアクティブ演出
下の映像は、ドラムのキック2回→ドラムのスネア→ギターという順序で音が鳴った時の空間演出です。
このような楽曲を構成する各パートの音に反応する演出を作るために、先述のイベントビジュアライザを用いて各パートの音量を色の明るさで表現して映像として配信しました。
左から順に下記トラックに反応しています。
1. キック
2. スネア
3. ハット
4. ギター(生演奏):ピッチ含む
5. ベース:ピッチ含む
6. 曲毎に任意に差し替える目立つ音
7. 曲毎に任意に差し替える目立たない音
8. ボーカル(生歌):ピッチ含む
9. ハモリ・コーラスなお、上から順に下記用途で4種類の出力をしています。
1. 音量の値そのまま
2. 音量の値を使い、減衰はゆるやかにしたもの
3. 音量の値が累積されていくもの(音を鳴らす度に明るくなって最大になると黒からやりなおし)
4. ピッチを取得しやすいよう輝度最大で固定して表示したもの予め送信するトラック数と役割を定めておくことで、ワールド・エフェクトをデザインして頂いたMikipomさんとのやりとりが円滑になるようにしました。
前提
各パートに反応する演出を作るためには、各パートがバラバラになったパラデータが必要になります。
今回は自分達の楽曲のため各パートの音源を簡単に用意できましたが、そうでない場合もiZotope RX7
といったツールを用いることである程度パートを分離した音声を用意できると思います。ルーティング
NETDUETTOのVSTプラグインでは各演奏者の音声をバラバラに出力する機能があるため、これを用いて遠隔地のボーカルトラック単体を抜き出しています。Ableton Live上での設定
Ableton Live Suiteの各トラックに、音は出力されない状態で各パートの音声を読み込みます。
音が出力されるのは、別途用意した各パートが混ざった伴奏音声、リアルタイムに入力する歌とギターのトラックのみです。
それぞれのトラックに音量をOSCに変換して送信するMax for Liveプラグインを挿すことで、それぞれのトラックから音量の値がOSCで送信されます。
演出イベントの管理
下の映像は、ライブの開始SEが終了して1曲目のイントロが始まり、ワールドそのものが出現する演出です。
このように、オーディオリアクティブな要素とは別に、曲中の特定のタイミングに合わせて演出を行うために、Ableton Liveのタイムライン上に演出イベントのトリガーを配置しました。
トリガーはMIDI Note・MIDI Pitch BendをOSCに変換し、OSCをイベントビジュアライザで映像に載せることで演出イベントを発火させます。制作から実際に再生されるまでの流れ
- デザイン担当のMikipomさんが演出要素をShader Onlyで作る
- デザイン担当のMikipomさんがUnityのTimelineで曲に合わせてシェーダパラメータを操作したデモを作る
- Timelineを元にどのタイミングでどのパラメータをどの値に動かしたいかという進行表を作る
- パラメータは
0~1
の範囲で表現できるようにしておく- 動かしたいパラメータをそれぞれMIDI Note Num(ドレミファソラシド…)に割り当てる
- 進行表を元に筆者がAbleton Live上にパラメータを操作するためのMIDI Noteを配置していく
- それぞれのMIDI Note上でMIDI Pitch Bendを用いて0~1のパラメータを表現する
- OSCでMIDI Note NumとPitch Bendを送信する
- イベントビジュアライザを用いてパラメータをそれぞれ異なるピクセル上に色で表す
- ワールドに配置されたシェーダー側で特定のピクセルの色を読んで演出を再生する
進行表
いつ、どのマテリアルのどのパラメータをどこまで動かすかという進行表です。
演出イベント管理
動かしたいパラメータの数だけトラックを用意しました。
曲の特定のタイミングに合わせてMIDI Noteが配置されています。
演出制御用MIDI Note
あるオブジェクトの幅を表すパラメータを0から1まで動かすMIDI Noteです。
Notesの欄に記載されている音階がマテリアルのパラメータに対応しています。
例えば:
- ラ:アーティストを表示するか否かのフラグ
- シ:ワールド全体の色相
- ド:床の高さモーション収録
ボーカルモーションの遠隔収録
ボーカルのモーション収録はバーチャルモーションキャプチャーとEVMC4Uを用いて行いました。
バーチャルモーションキャプチャーは、SteamVR対応デバイスを用いて簡単にVRMモデルのモーションキャプチャを行い、さらにそのモーションをOSCで送信することができるソフトウェアです。
EVMC4Uは、バーチャルモーションキャプチャーから送られてきたモーション情報をUnity上のVRMモデルにリアルタイムに適用できるスクリプト群です。
トラッキングデバイスはHTC VIVE CEとVIVE Trackerを用いました。
ボーカルPCからバーチャルモーションキャプチャーでモーションをOSCで筆者宅ネットワークのグローバルIPに向けて送信し、筆者PCのEVMC4Uで受け取ります。
この方法を用いると、NETDUETTOの音声と殆どズレのないモーションを再生することができます。ギターを生演奏しながらギターモーションを収録する
M5StickCをギター側、右手側で2つとiPhone11を用いて、ギターの演奏を簡易的にトラッキングします。
M5StickCはディスプレイがついた、加速度センサやWi-Fi接続機能など多くの機能を持つ小型で低価格なマイコンです。
M5StickCでセンシングした値をWi-Fi経由でPCに飛ばすことでアバターのモーションを表現しました。
画像左はローフレットを押さえて右下の方向を見ている筆者、画像右はハイフレットを押さえて左上の方向を見ている筆者です。これは過去にアコースティックカバー生放送を行った時に制作したものです。
実際の動きは下記放送アーカイブからご覧いただけます。
【memex】アコースティック歌生放送! #めめなま 【3000人記念】 - YouTubeギター側
フレット
ギターの指板における左手の位置と、ギターの姿勢をセンシングします。
左手の位置は超音波センサ(HC-SR04)、ギターの姿勢はM5StickC内蔵の加速度センサでセンシングしました。超音波センサは超音波を発しそれが対象にぶつかってから帰ってくるまでの時間を音速で割ることで対象との距離を求めることができるセンサです。
これをギターのヘッド裏に取り付けることで左手の位置をざっくりと計測します。
実際にギターを弾いてみるとわかりますが、抑えるポジションによっては手首はネックの真裏の高さにないこともあり、正確にトラッキングすることはできません。左手が動いていることがわかるくらいではあります。
また、上記画像からわかるように筆者のアバターの手は抽象化されているので、指のあるアバターではさらに別の対策が必要になると思われます。
FinalIKを用いてアバターの手の位置をギターの指板上に固定して動かしています。
FinalIKは逆運動学を用いてアバターの手などを自然に目標の位置に到達させることができるアセットです。画像の手前にある二つの白いSphereが、それぞれ「1Fを押さえているときにアバターの左手があってほしい位置」「12Fを押さえているときにアバターの左手があってほしい位置」です。
左手は、2つのSphereを結んだ直線上を超音波センサの距離値に応じて移動しています。実装はこのようになっています。
ultrasonicAndIMUWifi.c#include <M5StickC.h> #include <WiFi.h> #include <WiFiUDP.h> #include <OSCMessage.h> /** * WiFiアクセス用 */ const char ssid[] = "[お使いのルーターのSSID]"; const char pass[] = "[パスワード]"; static WiFiUDP wifiUdp; static const char *kRemoteIpadr = "[受け取りたいPCのプライベートIP]"; static const int kRmoteUdpPort = 8001; //送信先のポート static const int kLocalPort = 7000; //自身のポート boolean connected = false; /** * HCSR-04用 */ int Trig = 26; int Echo = 36; int Duration; float Distance; /** * IMU用 */ float pitch = 0.0F; float roll = 0.0F; float yaw = 0.0F; float temp = 0; /** * Setup */ static void WiFi_setup() { WiFi.begin(ssid, pass); while( WiFi.status() != WL_CONNECTED) { delay(500); } } static void Serial_setup() { Serial.begin(115200); Serial.println(""); // to separate line } static void Hcsr04_setup() { pinMode(Trig,OUTPUT); pinMode(Echo,INPUT); } void setup() { Hcsr04_setup(); Serial_setup(); WiFi_setup(); M5.begin(); M5.IMU.Init(); } void loop() { /** * 距離計測 */ digitalWrite(Trig,LOW); delayMicroseconds(1); digitalWrite(Trig,HIGH); delayMicroseconds(11); digitalWrite(Trig,LOW); Duration = pulseIn(Echo,HIGH); if (Duration>0) { Distance = Duration/2; Distance = Distance*340*100/1000000; // ultrasonic speed is 340m/s = 34000cm/s = 0.034cm/us OSCMessage msgDistance("/leftHand/distance"); msgDistance.add(Distance); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgDistance.send(wifiUdp); wifiUdp.endPacket(); } /** * IMU計測 */ M5.IMU.getAhrsData(&pitch,&roll,&yaw); M5.IMU.getTempData(&temp); /** * OSCSend */ OSCMessage msgPitch("/guitar/pitch"); msgPitch.add(pitch); OSCMessage msgRoll("/guitar/roll"); msgRoll.add(roll); OSCMessage msgYaw("/guitar/yaw"); msgYaw.add(yaw); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgPitch.send(wifiUdp); wifiUdp.endPacket(); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgRoll.send(wifiUdp); wifiUdp.endPacket(); wifiUdp.beginPacket(kRemoteIpadr, kRmoteUdpPort); msgYaw.send(wifiUdp); wifiUdp.endPacket(); delay(33); }ギター姿勢
M5StickCの加速度センサを用いて、ギターの姿勢を特定の軸に限って表現します。
加速度センサはスマートフォンの縦持ち、横持ちなどを判定する際に使われているセンサーで、デバイスの姿勢をある程度正確に計測することができます。(重力が影響しない軸の回転が計測できません)
ギターが地面に対してどの程度垂直かもしくは平行か、を角度で計測して、ギターのrotationに適用しています。右手側
M5StickCの加速度センサを用いて、ギターをピッキングする動きを表現します。
仕組みはギター姿勢と同じです。
FinalIKでアバターの右手をギターのピックアップ上に配置し、rotationだけを操作しています。頭
iPhone 11に搭載されているFaceTrackingを使ってアバターの頭と腰を動かしています。
iPhoneアプリ「ZIG SIM Pro」を用いてFaceTrackingのパラメータをOSCでPCに送信しています。
ZIG-Project https://zig-project.com/FaceTrackingのパラメータから顔の姿勢をあらわす
facerotation
を抜き出しアバターのHeadボーン、Spineボーンに適用しています。
首から下が固定されていて頭だけ動いているのが若干不自然だったので腰も同時に動かしています。実装はこんな感じです。
OSCHeadAndSpineRotator.cspublic class OSCHeadAndSpineRotator : MonoBehaviour { float pitch, roll, yaw; const string uuid = "[ZIG SIM内で確認できるデバイスID]"; private Animator animator; private Transform head, spine; private Quaternion initalRotationl, headInitialLocalRotation, spineInitialLocalRotation, preHeadLocalRotation, preSpineLocalRotation; [SerializeField] Vector3 eularRotationOffset; [SerializeField] float slerpRate = 10f; [SerializeField] uOscServer server; void Start() { server.onDataReceived.AddListener(OnDataReceived); animator = GetComponent<Animator>(); head = animator.GetBoneTransform(HumanBodyBones.Head); spine = animator.GetBoneTransform(HumanBodyBones.Spine); headInitialLocalRotation = head.localRotation; spineInitialLocalRotation = spine.localRotation; } void OnDataReceived(Message message) { if (message.address == "/ZIGSIM/" + uuid + "/facerotation") { Quaternion q = new Quaternion( float.Parse(message.values[0].GetString()), float.Parse(message.values[1].GetString()), float.Parse(message.values[2].GetString()), float.Parse(message.values[3].GetString()) ); var thisFrameHeadLocalRotation = Quaternion.Slerp(preHeadLocalRotation, headInitialLocalRotation * q * Quaternion.Euler(eularRotationOffset), Time.deltaTime * slerpRate); var thisFrameSpineLocalRotation = Quaternion.Slerp(preSpineLocalRotation, spineInitialLocalRotation * q * Quaternion.Euler(eularRotationOffset), Time.deltaTime * slerpRate); // 取得した回転の8割くらい頭を回転させ、4割くらい腰を回転させます(この値は好み) head.localRotation = Quaternion.Lerp(headInitialLocalRotation, thisFrameHeadLocalRotation, 0.8f); spine.localRotation = Quaternion.Lerp(spineInitialLocalRotation, thisFrameSpineLocalRotation, 0.4f); preHeadLocalRotation = thisFrameHeadLocalRotation; preSpineLocalRotation = thisFrameSpineLocalRotation; } } }VR空間へのツイートのリアルタイム表示
配信映像に直接ツイート本文を載せ、リコンストラクタ側でその画像に透過処理をかけて空間に画像として表示しています。
UnityでTwitter APIを利用するためのTwityというライブラリを用いてハッシュタグツイートを約10秒間隔で更新しました。
GitHub - toofusan/Twity: Twitter API Client for Unity C# (ex-name: twitter-for-unity) https://github.com/toofusan/Twity
テキストはディスプレイに直接Canvasで表示しています。出現アニメーションはリコンストラクタ側でつけることもできますが、リコンストラクタ側に状態を持たせたくなかったので、配信映像に載せる前の段階でアニメーションをつけました。
文字アニメーションにはText Juicerというプラグインを用いました。
GitHub - badawe/Text-Juicer: Simple tool to create awesome text animations https://github.com/badawe/Text-Juicer返しモニタ
アバターの姿とUnityのコンソール出力を確認できる返しモニタを用意します。
主に下記2点の目的で返しモニタが必要でした。
- 意図通りのモーションが反映されているか確認するため
- ライブのMC時間にツイートを読み上げるためUnityのマルチディスプレイ機能を用いて、配信用テクスチャを表示するウィンドウと別のウィンドウとして画面に表示します。
ライブ中自分達のアバターのモーションがどのようになっているか、またどんなツイートが観客側に表示されているかを一目で確認できるように、アバター表示に重ねてコンソール出力をCanvasに表示しています。
コンソール出力を表示する実装はこちらの記事を参考にしました。
【Unity】ゲーム画面にDebug.Logを出したい! - うら干物書き https://www.urablog.xyz/entry/2017/04/25/195351返しモニタウィンドウをボーカルのPCにDiscordの画面共有で送ることで、同じ返しモニタを共有できるようにしています。
HLSサーバー
諸々の事情を考慮して映像はAWSで構築した専用のストリーミングサーバーから配信しました。
構成は下記の通りです。下記記事を参考にして殆どその通りの手順で進め、4時間程度で動作確認までできました。(AWSすごい)
OBSとAWS Elemental MediaLiveでライブ配信をしてみた | Developers.IO https://dev.classmethod.jp/articles/live-aws-elemental-medialive-with-obs/
MediaPackage で Amazon CloudFront を使用する - AWS Elemental MediaPackage https://docs.aws.amazon.com/ja_jp/mediapackage/latest/ug/cdns-cf.htmlVRChat上で再生可能にする
- リコンストラクタのシェーダで表現されたライブに必要なオブジェクトをワールドに配置する
- アーティストのモデル、ステージ、GPUパーティクルなど
- VRChat SDKのコンポーネント
VRC_SyncVideoStream
を用いてCloudFrontで配信しているHLS映像のURLにアクセス- 映像をワールド内に配置したカメラで撮影し1920 * 1080のRenderTextureに書き込む
- 各オブジェクトのマテリアルのテクスチャとして3のRenderTextureを指定する
VRChat上で空間編集を可能にする
下の映像のように、だいたいのオブジェクトを手で掴んで再配置できるようにしました。
同じインスタンスにいる観客同士のみに反映されます。
- オブジェクトにVRChat SDKのコンポーネントVRC_Pickup
をつける大変だったこと
解説は以上です。制作して大変だったことをメモしておきます。
シェーダーむずかしい
シェーダーでアニメーションさせるVATというものを使ったらモーションを画像で表現できるらしい、と聞いてシェーダーを調べ始めたものの、全然理解できなかった
- これがなかったら1行も読めるようにならなかったと思う
- Unity Shader Programming Vol.01 (v.2.2.1)【PDF】 - XJINE's - BOOTH https://booth.pm/ja/items/931290
- リファレンスがわからない
- 未だにどう調べたら欲しい情報にたどり着けるかわからない HLSL? CG?つくっていることを秘密にしたい時、だれにも聞けない
12月くらいから制作していたのですが、人に聞けばおそらく一瞬で解決しそうなことで割と詰まってしまいました。
何を作っているかを秘密にしたいときって情報収集の仕方が難しいですよね……エンコードこわい
- H.264エンコードについて詳細に調べようと思ったけど挫折してしまった
- 種類がありすぎる ドキュメントが長すぎる
- ある程度ピクセルをブロックにまとめてやってるっぽいとか彩度は減衰しやすいっぽいという認識だけが残っています
UnityCaptureのピクセルズレ?
UnityCaptureでOBSに入力した画像は微妙にピクセルがズレていて、この用途では使えなかったのでビルドしてOBSのウィンドウキャプチャを使いました
トラブル起きたら一発アウトで再起動が必要になるので、本当に怖かったです……映像ストリーミング、お金かかりすぎでは
#解釈不一致 配信コスト、タイムラグが・・・()
— ぴぼ | memex Gt (@memex_pibo) May 31, 2020
ま、まあライブハウスのノルマ2回分と考えれば余裕ですよ… pic.twitter.com/SSzPBLcuksおわりに
遠くない将来、好きな姿で、オーディオリアクティブな演出が行われるライブ会場で、リアルタイムに遠隔地のメンバーとセッションするVRライブをいちユーザーが開催できて、それに観客が何人でも同時に参加できる未来が来てほしいなと思っています。
2020年7月現在、生演奏しながら楽器演奏のモーションをキャプチャするのが難しいとか、音声と空間演出のタイムコードを合わせるのが難しいといった様々な課題があり、そういったVRライブを開催するのは容易ではありません。
エンジニアとしての自分にとって「#解釈不一致」は、望むVRライブの未来を今いちユーザーとして利用可能な技術で実現する挑戦でした。
いつか「昔はこんな面倒なことやらないとこういうライブできなかったんだな~」と言える未来が来ることを願っています。参考
GitHub - sugi-cho/Animation-Texture-Baker https://github.com/sugi-cho/Animation-Texture-Baker
Unityでスクリプトを使わずに流体を計算する – EL-EMENT blog http://el-ement.com/blog/2018/12/13/unity-fluid-with-shader/
ビット演算まとめ - Qiita https://qiita.com/qiita_kuru/items/3a6ab432ffb6ae506758
ライブ エンコーダの設定、ビットレート、解像度を選択する - YouTube ヘルプ https://support.google.com/youtube/answer/2853702?hl=ja ↩
- 投稿日:2020-07-12T20:33:22+09:00
【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); }
- 投稿日:2020-07-12T19:31:48+09:00
Unityでもクリップボードの画像を使いたい【Windows編】
このように思って調べてみたのですが、2020年7月時点でUnityは、クリップボードの文字列しか扱うことができません。
Scripting API: GUIUtility.systemCopyBuffer - Unity
この機能自体は、ゲーム内でフレンドコードをクリップボードにコピーするために使われているそうです。
しかし、私のように「クリップボードの画像も使いたい!」という人が必ずどこかにいると思うので、今回この記事を投稿します。成果物はこちらのリポジトリです。
https://github.com/umetaman/UnityNativeClipboardまた、一言で「画像」と言ってもたくさんの形式があるので、今回は各チャンネル8ビットで3チャンネルから4チャンネルの画像を対象にします。JPEGとPNG-32です。
環境
- Unity2019.4.3f1(LTS)
- Windows 10 Pro(2004)
- Visual Studio 2019
はじめに
UnityのC#ではサポートされていない機能を使うために、NativePluginを使用します。
簡単に説明すると、
- C, C++, Objective-Cで書かれたコードをC#から呼び出すことができる。
- OSのAPIを呼び出すコードや、既存のC/C++のライブラリを再利用できる。
というものです。
C#でもOSのAPIを呼ぶことはできますが、構造体の定義やマーシャリングなど、いろいろと面倒くさいので、今回はC++でWin32APIを使ったメソッドを定義して、それをC#から呼び出すという方法で実装します。
大まかな流れ
- C++でWindowsのクリップボードから画像の情報を取り出す。
- C#で画像のビットマップを保存する領域を確保して、C++にアドレスを渡す。
- C++でC#から渡されたアドレスにビットマップをコピーする。
- C#でコピーされたビットマップをもとに、UnityのTexture2Dを作成する。
画像のデータをコピーするのにC++とC#の間を2往復していますが、後ほど述べるWindowsのBitmapの構造上、こうするしかありませんでした。
C++(NativePlugin)
まず、Visual StudioでDLL(ダイナミックリンクライブラリ)のプロジェクトを作成してください。
ウェブ上には様々な解説がありますが、最初はMicrosoftの解説を読んだほうがいいと思います。チュートリアル: 独自のダイナミック リンク ライブラリを作成して使用する (C++)
GetClipboardData
Win32APIではいくつかの段階を踏むことで、クリップボードのデータを取り出すことができます。例えば、画像であれば、次の通りです。
- クリップボードを開く。
- 欲しいフォーマットのデータがあるか確認する。
- フォーマットを指定して、データのハンドルを受け取る。
- ハンドルを欲しいデータへのポインタとしてキャストする。
- コピーしたり、処理したりする。
- クリップボードを閉じる。
Windowsにおいて標準でサポートされているフォーマットについては、こちらを参照してください。
Clipboard Formats - Win32 apps | Microsoft Docs
今回は、DIB(Device Independent Bitmap, デバイスに依存しないビットマップ)が欲しかったので、CF_DIBを指定しています。
bool isOpened = OpenClipboard(NULL); if (isOpened) { if (IsClipboardFormatAvailable(CF_DIB)) { HANDLE hClipboardData = GetClipboardData(CF_DIB); LPVOID clipboardDataPtr = GlobalLock(hClipboardData); BITMAPINFO* bitmapInfoPtr = static_cast<BITMAPINFO*>(clipboardDataPtr); // つづく } }BITMAPINFOというのは、ビットマップ画像について、様々な情報が詰まっている構造体です。ここから画像の大きさ、ビットの大きさ、ピクセルのデータを取り出します。
BITMAPINFO (wingdi.h) - Win32 apps | Microsoft Docs
BITMAPINFOHEADER (wingdi.h) - Win32 apps | Microsoft DocsBITMAPINFO* bitmapInfoPtr = static_cast<BITMAPINFO*>(clipboardDataPtr); int width = bitmapInfoPtr->bmiHeader.biWidth; int height = bitmapInfoPtr->bmiHeader.biHeight; int bitsPerPixel = bitmapInfoPtr->bmiHeader.biBitCount;ビットマップの取り出し
それでは、いよいよ画像のピクセルを取り出していきます。いきなりですが、こちらの画像をご覧ください。WindowsにおけるBitmap画像ファイルの構造を示したものです。
引用:Windows bitmap - Wikipedia
(「Wikipediaかよ…」と思った人もいると思いますが、ぶっちゃけこれがいちばんわかりやすかったです。)この画像を見ると、どうやらビットマップのヘッダーの直後に画像のピクセルが配置されているみたいなので、先ほど取り出したBITMAPINFOのbmiHeaderのポインタをシフトして、ピクセルデータまで移動します。
BITMAPINFOHEADERのポインタから、unsigned char型のポインタにキャストして、BITMAPINFOHEADERの大きさ分シフトします。
さらに、画像によっては画像に使う色を定義したカラーテーブルが含まれている場合もあるので、さらにその分シフトします。オプション的な機能らしいので、カラーテーブルをわざわざ持っているJPEGやPNGは少ないそうです。今回は、24ビットと32ビットのみの対応ですが、1, 4, 8ビットの画像には必ず入っているそうです。// BITMAPINFOHEADERのbiSizeは、BITMAPINFOHEADERの大きさを示します。 unsigned char* pixelData = (unsigned char*)(bitmapInfoPtr)+bitmapInfoPtr->bmiHeader.biSize; // カラーテーブルがあるときはその分シフトする if (bitmapInfoPtr->bmiHeader.biCompression == BI_BITFIELDS) { pixelData += bitmapInfoPtr->bmiHeader.biClrUsed; }ようやく、ビットマップまでたどり着いたので、いよいよC#から渡されたバッファにコピーしていきます。そこで、またまたこちらの画像をご覧ください。ピクセルデータの部分を拡大したものです。
Windows Bitmapのピクセルデータは、必ず1ライン(横幅, Width)が4バイトの倍数になるように確保されています。画像の大きさが足りない場合は、0で埋められます。
思わぬ落とし穴ですね。私はこれに気づくのに2日かかりました。よって、コピーするときは、これを考慮して1ラインずつコピーすることにします。
int bytesPerPixel = bitmapInfoPtr->bmiHeader.biBitCount / 8; // 1ピクセル当たりのバイト数 int bytesPerLine = width * bytePerPixel; // 1ライン当たりのバイト数 bytesPerLine += bytePerLine % 4 == 0 ? 0 : 4 - bytesPerLine % 4; // 4の倍数になるように増やしてあげる unsigned char* dst = buffer; // C#から渡されたバッファという想定 unsigned char* src = pixelData // Windows Bitmapのピクセルの先頭のポインタ // 1ラインずつコピーする for (int h = 0; h < height; h++) { memcpy( dst + (width * h * bytesPerPixel), src + (h * bytesPerLine), width * bytesPerPixel ); }コードは部分的にしか示しませんでしたが、これでC++側の実装は完了です。DLLをビルドしてUnityのプロジェクトにインポートしましょう。
C#(Unity)
NativePluginで定義したメソッドを静的クラスのメソッドとして定義します。DllImportAttributeでプラグインの名前を指定します。
DllImportAttribute クラス (System.Runtime.InteropServices) | Microsoft Docs
using System; using System.Runtime.InteropServices; using UnityEngine; public static class NativePlugin { // クリップボードに画像があるか [DllImport("Clipboard")] private static extern bool hasClipboardImage(); // クリップボードの画像の大きさと、1ピクセル当たりのビット数 [DllImport("Clipboard")] private static extern void getClipboardImageSize(ref int width, ref int height, ref int bitsPerPixel); // Bufferに画像のピクセルを書き込む [DllImport("Clipboard")] private static extern bool getClipboardImage(IntPtr buffer); }定義の仕方には、いろいろな流儀がありますが、私はNativePluginのメソッドはprivateにして、別にpublicなメソッドを定義して間接的に呼び出すようにしています。
バッファの確保
getClipboardImageSize(ref int width, ref int height, ref int bitsPerPixel)
とgetClipboardImage(IntPtr buffer)
というように、大きさの取得とデータの取得を分けているのは、C#側で事前に大きさを知ってバッファを確保するためです。
width, height, bitsPerPixelにref(参照渡しのキーワード)を付けているのは、NativePluginから一気に値を返してもらうためです。int width = 0; int height = 0; int bitsPerPixel = 0; getClipboardImageSize(ref width, ref height, ref bitsPerPixel);得た値を元に、バッファを確保します。今回は1ピクセルが24ビットと32ビットで、それぞれ1チャンネルが8ビット(1バイト)なので、byte配列をバッファとします。
また、C#では、ガベージコレクションによってメモリのアドレスが変わってしまうことがあるため、ハンドルを割り当てて、位置を固定してあげないといけません。「NativePluginにコピーしてもらうまで、じっとしてなさい!」と命令してあげます。コピーが終わったら、ちゃんと
Free()
で解放してあげます。GCHandle 構造体 (System.Runtime.InteropServices) | Microsoft Docs
// チャンネル数 int channel = bitsPerPixel / 8; // C#側の領域を用意する byte[] buffer = new byte[width * height * channel]; // GCによって移動しないように固定する。必ず開放する。 GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); // 確保したバッファのアドレス IntPtr bufferPtr = handle.AddrOfPinnedObject(); // クリップボードからコピーする bool successCopy = false; successCopy = getClipboardImage(bufferPtr); // 解放 handle.Free();これでようやくC#側に持ってくることができました。
Texture2Dの作成
あとは、クリップボードから持ってきたピクセルの配列を使って、Texture2Dを作成しましょう。
UnityのTexture2Dでは、ピクセルの配列から画像を作成することを想定していたのか、LoadRawTextureData
という素敵なメソッドが用意されているので、それを使います。TextureFormat.BGRA32
と指定すれば、そのまま流し込めます。残念ながらTextureFormat.BGR24
という形式はUnityにはないようなので、Color32[]
でアルファチャンネルの値を255で埋めてごまかします。Texture2D-LoadRawTextureData - Unity スクリプトリファレンス
Texture2D texture = new Texture2D(width, height, TextureFormat.BGRA32, false); // BGRA if(channel == 4) { texture.LoadRawTextureData(buffer); } // BGR else if (channel == 3) { Color32[] pixels = new Color32[width * height]; for (int i = 0; i < pixels.Length; i++) { pixels[i].b = buffer[channel * i]; pixels[i].g = buffer[channel * i + 1]; pixels[i].r = buffer[channel * i + 2]; pixels[i].a = (byte)255; } texture.SetPixels32(pixels); } texture.Apply();これでTexture2Dに変換できたので、Unityのオブジェクトにセットしてみましょう!
次のGIFは、クリップボードの画像をuGUIのImageに反映したものです。突っ込まれそうなポイント
なぜ、CF_DIBなのか。
Device Independent Bitmapという名前に惹かれました。CF_BITMAPでも同様のことができると思います。
使いどころは?
この機能を必要としている人たちもいます。UnityはVJなんかにも使われているので、クリップボードの画像を使うのもいいのではないでしょうか。Visual Effect Graphにも持っていけます。
アセットストアで売れる?
今回はすべてのフォーマットの画像をサポートしきれないなと思ったので、コードを公開しました。バグの修正やサポート形式の追加をお待ちしています。
まとめ
今回は、Windowsのクリップボードに保存されている画像をUnityで使うために、NativePluginでWin32APIを呼び出して、画像をコピーするという方法を取りました。
OSの中に保持されているデータを読み取っているので、これを応用すれば、ウィンドウをキャプチャしてUnityで再生するみたいなこともできそうです。記事中のコードは、説明のためにかなり内容を省略しているので、自分のプロジェクトに組み込みたい方は、GitHubのリポジトリを参照してください。
https://github.com/umetaman/UnityNativeClipboard近々、macOS向けの記事も書く予定です。こちらもかなりハマるポイントがありました。お楽しみに~
- 投稿日:2020-07-12T16:52:19+09:00
[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 int
はIntPtr
です。- スタックベースの仮想マシンです。引数はスタックに積まれています。この記事で頻出の命令をメモしておきます。
- 関数の引数は右側を先にスタックに積みます。
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::Asvoidポインターをジェネリック型の
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::Copyvoidポインターからジェネリック型の
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::Copyvoidポインターから指定されたバイト数分、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::CopyBlockbyte型の
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::CopyBlockUnalignedvoidポインターへ指定されたバイト数分、バイト値を書き込む(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::InitBlockbyte型の
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::Addvoidポインターをジェネリック型として要素数分加算する
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::Subtractvoidポインターをジェネリック型として要素数分減算する
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); }
- 投稿日:2020-07-12T16:44:04+09:00
【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つ持つので、それに対応するparamA
とparamB
を用意します。片方は元の
ParameterExpression
をそのまま流用でOKです。
ExpressionVisitor
引数の式木のパラメータを新たに生成した
paramB
で置き換える役割です。
Expression.Lambda<Comparison<T>>
ここで
CompareTo
の呼び出しを構築します。expressionを
p => -p
だったとき// expressionを p => -pとする (p1, p2) => -p1.CompareTo(-p2)というようになります。
expression.Body
とexp2.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)
となりますが問題ありません。
ParameterExpression
はName
プロパティが同一でもインスタンスが別(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
が別の変数として扱われるためです。
- 投稿日:2020-07-12T15:06:23+09:00
共有フォルダを利用してnugetパッケージを管理する方法
? はじめに
通常、nuget パッケージを管理する場合には nuget.org を利用するか nuget サーバを作成して管理することが多いと思います。
ただ、小規模な企業や事務の方々にはなかなかにハードルが高いと思います。
これは技術的な話だけではなく、コンプライアンスやインフラ導入までの手続き的な意味も含めてです。そこで今回はオープンな場での nuget パッケージの管理は禁止されており、なおかつ nuget サーバを独自で導入するのも難しい方向けのソリューションを紹介します。
? 注意事項
今回紹介する方法では共有フォルダにアクセスできる人間であれば誰でも自由に nuget パッケージを追加・編集・削除できてしまうので注意が必要です。
気休め程度ですが隠しフォルダにするなどの対応や、アクセス権限をいじれるのであれば適切なアクセス権限の付与を行うなどの対策が必要です。
あくまでも本来やらねばならないことをすっ飛ばして簡易的に nuget パッケージを管理したい場合の代替案だということは胸に深く刻んでおきましょう。? How To
1. nuget パッケージを作成する
何はともあれ、管理したいライブラリがなければ話は進まないため、簡単なライブラリを用意していきます。
.NET Core を利用している場合は非常に簡単に dll プロジェクトから nuget パッケージを作成することができます。a. dll プロジェクトを作成
まずは適当な位置にプロジェクトを作成します。
今回は F# でサンプルを作成していますが C# でもまったく同じ方法で実現可能です。powershelldotnet new classlib -lang="F#" -o="SampleProject"今回はライブラリの中身にあまり興味がないため、デフォルトのまま進めます。
b. nuget パッケージを作成
通常の dll を作成する場合は
dotnet build
コマンドを使ってpowershelldotnet build -c="Release"とするだけで作成できます。
nuget パッケージを作成する場合には
dotnet pack
コマンドを使ってpowershelldotnet pack -c="Release"とすれば簡単に作成することができます。
作成された nuget パッケージは.nupkg
という拡張子で Relase フォルダに出力されます(デフォルトの場合)。
dotnet pack
コマンドには他にもいろいろなオプション機能があるので 公式HP を参考にカスタマイズしてみてください。2. nuget パッケージ管理用のフォルダを用意する
それでは作成した nuget パッケージを管理するためのフォルダを用意します。
と、いっても共有フォルダ上に1つフォルダを作成するだけなので、なんてことない作業です。今回は
共有フォルダを用意するのが面倒だったのでDドライブ直下にフォルダを用意しています。powershellmkdir D:/nugetフォルダ名は nuget としましたが、別になんでも問題ありません。
フォルダを作成したら、ココに nuget パッケージを移動します。
以下のようになっていれば OK です。
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"本当に追加されたかは
dotnet nuget list source
コマンドで確認することができます。
これで 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.hellopowershell# dotnet run コマンドを利用して実行 dotnet runこれで簡単に 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 パッケージ管理用フォルダに移動させます。
以下のような形になっていれば正解です。
b. コンソールアプリで参照している nuget パッケージのバージョンを更新する
次に 4. で作成したコンソールアプリの方を更新します。
更新方法は簡単で、もう一度dotnet add package SampleProject
を実行するだけです。powershell# カレントディレクトリを SampleProject にする cd "./SampleProject" # nuget パッケージをプロジェクトに追加する dotnet add package SampleProject?おわりに
今回は dotnet コマンドを利用して、環境によらない方法での紹介としましたが Visual Studio を利用できる環境であればさらに簡単に済ませることができます。
こういったケースで困ることはあまりないと思いますが、何かしらの一助になれば幸いです。
- 投稿日:2020-07-12T00:01:18+09:00
.NET Coreの国際化リソースにデータベースを使う
.NET Coreの国際化リソースにデータベースを使う
前回に関連した内容です。
.NET Coreの国際化対応はデフォルトでは
.resx
を使いますが、それ以外の方法も使える仕組みになっています。今回はデータベースのテーブルに保存した内容を用いて国際化対応をします。
その前にデフォルト実装の確認
デフォルトではLocalizationServiceCollectionExtensionsの
AddLocalization
メソッドを実行することで.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.csnamespace DbStringLocalizerSample.Localizer { /// <summary> /// データベースで管理されている国際化リソースのレコード /// </summary> public class LocalizationRecord { public string Key { get; set; } public string Ja { get; set; } public string En { get; set; } } }データベースから取得したレコードを保持するクラス
DbLocalizedStringSource.csusing 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.csusing 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.csusing 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.csusing 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.cspublic void ConfigureServices(IServiceCollection services) { //...省略... //AddLocalizationより前にDbStringLocalizerFactoryを登録する services.AddTransient<DbLocalizedStringSourceProvider>(); services.AddSingleton<IStringLocalizerFactory, DbStringLocalizerFactory>(); services.AddLocalization(); }使用方法
使用方法は
.resx
を使うときと同じです。まずカテゴリ用に2つのクラスを用意します。
Item.csnamespace DbStringLocalizerSample.Dummy { public class Item {} }Message.csnamespace 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
を使うことができないこれらを改善すればもう少し実用的なものになりそうです。