VRChat Advent Calendar 12月3日(米国時間)の記事を書かせていただく神城アオイです。
スクロールイベントカレンダーとか作ってる人です。
最近はVRCスクールのワールドとギミック全般を担当したりもしてます。
今回は、そのVRCスクールで名簿の管理に使用したギミックより、外部サーバーに登録された文字列をVRChat内で読み取る手法について、備忘録も兼ねて書いていきたいと思います。ちなみになぜC# onlyなのかというと、私が現状一番操作に慣れている画像の生成方法が C# を使用した方法だからです。
それでは早速。
大まかなフローは以下のようになります。
ちょっとBit演算するだけだよ
FFMpegの出番です
DynamicImageまだですか
RenderTextureからGetPixelできれば、だいぶ楽になるんですけどねぇ
ここでもBit演算が出てくるよ
テクスチャの設定とかが割とシビア
とりあえず、「神城アオイ」という文字列をVRChatで受け取りたいと思います。
まず、一文字を0x0000 の形(4桁の16進数)にしたいと思います。これは、Unicodeなどの文字コードでも似たようなシステムを使用しており、それに倣った形にした為です。
一文字が16Bitになるので、上位8bitと下位8bitに分けて変換します。
上位8btiは右に8bitシフトして変換し、下位8bitは上位8bitに0を掛けてから変換します。
そしてそれらを16進数表記の文字列に変換して結合します。
ちなみに、Char型はInt型に黙示的に変換できます。
string source = "神城アオイ";
string byteStr = "";
foreach (char c in source)
{
//上位8bitを変換
string top = Convert.ToString(c >> 8, 16).PadLeft(2, '0');
//下位8bitを変換
string buttom = Convert.ToString(c & 0x00FF, 16).PadLeft(2, '0');
//結合
byteStr += byteStr = top + buttom;
}
4bit(16進数1文字)を一色にします。本来であれば画像の1pxに一文字以上の情報を含みたかったんですが、VRChatがなかなかDynamicImage(外部から画像を取得するコンポーネント)をリリースしてくれないので、動画にする必要があります。よって、圧縮の影響も考慮する必要が出てくるんですね。
圧縮の影響が想像以上に強かった為、R、G、Bそれぞれに含められる情報は2bitづつ(4段階)しか含ませられませんでした。(FFMpegのエンコード設定がよく理解できていないので、もっと良いエンコード方式があるかもしれません。)各色には以下の要領でデータを詰めていきます。
R: 16進数1文字を4で割った余りを、さらに4で割って0-1の範囲にした値を格納します。
G: 16進数1文字を4で割った商を 、さらに4で割って0-1の範囲にした値を格納します。
B: データが有る場合は1、ない場合は0を格納します。
あ、でも実際に渡す値は0-255なので、それに合わせて 255 倍した値を渡します。
Color[] colors = Array.Empty<Color>();
foreach (char c in byteStr)
{
int i = Convert.ToInt32(c.ToString(), 16);
int d = i / 4;
int r = i % 4;
result = colors.Concat(new Color[] { Color.FromArgb(255, 255 / 3 * r, 255 / 3 * d, 255) }).ToArray();
}
画像全体を黒で塗りつぶした後、ここで得られた色を8px x 8px の正方形で左上から順に塗りつぶしていきます。
なんでそんなに大きなサイズで塗りつぶすのかって?動画に圧縮されて隣接ピクセルと色が混じるからですね。
あ、言い忘れてましたが、事前にNuget から System.Drawing.Common を導入しておいてください。
int pixelSize = 8;
int imageSize = 1024;
//描画エリアを用意
using Bitmap bitmap = new Bitmap(imageSize, imageSize);
using Graphics graphics = Graphics.FromImage(bitmap);
//黒で塗りつぶす
using SolidBrush brush = new SolidBrush(Color.Black);
graphics.FillRectangle(brush, new Rectangle(0, 0, imageSize, imageSize)
//先ほど変換した色で塗りつぶす
for (int j = 0; j < colors.Length; j++)
{
brush.Color = colors[j];
Rectangle rect = new Rectangle(pixelSize * j, pixelSize * i, pixelSize, pixelSize);
graphics.FillRectangle(brush, rect);
}
そして、最後にFFMpegを使用して動画に変換します。
string ffmpeg = @"D:\home\ffmpeg.exe";
string tmppng = @"D:\home\tmp.png";
string tmpmp4 = @"D:\home\tmp.mp4";
//画像をファイルに保存
bitmap.Save(tmppng, ImageFormat.Png);
//ffmpeg に渡す引数を生成
string arguments = $" -loop 1 -i {tmppng} -vcodec libx264 -profile:v baseline -pix_fmt yuv420p -t 1 -r 15 {tmpmp4} -y";
using Process process = Process.Start(new ProcessStartInfo(ffmpeg, arguments)
{
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
});
process.WaitForExit();
これで動画の用意が完成しました。あとは各自のサーバーへアップロードしてください。ここから先はVRChatでの処理になります。
ちなみに、こんな画像が生成されるはずです。
はい、やっとVRChatまで来ました。
ここからは制約が多いので、結構まわりくどい手法を取ります。
VRC_UnityVideoPlayerコンポーネントの PlayUrl メソッドを使用して動画を再生します。
そして、OnVideoEnd メソッドが発火したら、2フレームの間だけ Camera を有効にして、OnPostRender メソッド内でカメラの映した画像をTexrure2Dに書き込みます。
なんで2フレームかって?1フレームだとOnPostRenderが発火しなかったんですねぇ。
というか、RenderTextureから直接色が取れればそんな回りくどい事をしなくていいんですがね…
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.SDK3.Video.Components;
[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
public class VideoLoader : UdonSharpBehaviour
{
public VRCUnityVideoPlayer videoPlayer;
public VRCUrl vRCUrl;
public Camera cam;
public Texture2D tex;
private Rect _rect;
void Start()
{
_rect = cam.pixelRect;
SendCustomEvent(nameof(PlayVideo));
}
//動画を再生する
public void PlayVideo()
{
videoPlayer.PlayURL(vRCUrl);
}
//動画の再生が終わったら、カメラを一瞬Onにする
public override void OnVideoEnd()
{
SendCustomEvent(nameof(EnableCam));
SendCustomEventDelayedFrames(nameof(DisableCam), 2);
}
//カメラの有効化
public void EnableCam()
{
cam.enabled = true;
}
//カメラの無効化
public void DisableCam()
{
cam.enabled = false;
}
//カメラが捉えた画像を書き込む
void OnPostRender()
{
tex.ReadPixels(_rect, 0, 0, false);
tex.Apply(false);
}
}
ここまでくればあとは目的のピクセルから色を読みだして、ちょちょっとbitの計算をすればお終いです。
まず、横一列にデータが有る範囲の色を取得し、バイト文字列に変換していきます。
public string GetByteStringFromImage()
{
string result = "";
int y = tex.height - pixelSize / 2;
for (int ix = 0; ix * pixelSize + pixelSize / 2 < tex.width; ix++)
{
int x = ix * pixelSize + pixelSize / 2;
Color col = tex.GetPixel(x, y).gamma;
//bが0の時はデータなしなので、ここでスキップ。圧縮ノイズを考慮して0.5以下にした。
if (col.b < 0.5f) break;
int s = Mathf.RoundToInt(col.r * 3) + Mathf.RoundToInt(col.g * 3) * 4;
result += Convert.ToString(s, 16);
}
return result;
}
最後に得られたバイト文字列をバイト配列にしてデコードすれば、求めていた値が出てきます!
やることはエンコードの逆ですね。
一文字分、4バイトづつ取り出して文字に変換し、結合していきます。
public string GetStringFromByteString(string byteStr)
{
//一文字が4byteで構成されているので、一文字づつ
string[] byteStrArray = new string[byteStr.Length / 4];
for (int i = 0; i < byteStr.Length; i += 4)
{
byteStrArray[i / 4] = byteStr.Substring(i, 4);
}
string result = string.Empty;
for (int i = 0; i < byteStrArray.Length; i++)
{
int top = Convert.ToByte(byteStrArray[i].Substring(i, 2), 16) << 8;
int bottom = Convert.ToByte(byteStrArray[i].Substring(i + 2, 2), 16);
result += (char)(top + bottom);
};
return result;
}
読み込み用のビデオプレイヤーと再生用のメッシュレンダラー、キャプチャ用のカメラと記録用のテクスチャを用意していきます。
まず、あらかじめ作成しておくアセットがこちら。
RenderTexture1とRenderTexture2を作成する際は、Sizeを動画と同じにしておきます。
MipMapやFilterなどは、画像がぼやけてしまい正しく読み込めなくなる原因となるのでOFFとPointにします。
Texture2Dは、Photoshopなどで真っ白な画像を用意してください。ここに書き込んだデータを読み取ります。
こちらもぼやけなくする為にMipMapやFilterはOFFとPointにします。
また、データを書き込めるようにする為、ReadWriteEnabledをOnにします。
データサイズが大きいですが、Crunch圧縮等はしないでください。不可逆圧縮の為、書き込み自体が出来なくなります。
MaterialはShaderにUnlit/Textureを指定します。
また、テクスチャには先ほど作成したRenderTexture1を指定します。
次に、コンポーネントを用意していきます。これらはすべて同じゲームオブジェクトに付与します。そして、このGameObjectのLayerをMirrorReflectionに設定します。
MeshFilterのMeshはUnity組み込みのQuadを指定します。
MeshRendererのMaterialは先ほど作成したMaterialを指定します。
VRCUnityVideoPlayerのRenderModeをRenderTextureに設定し、TargetTextureに先ほど作成したRenderTexture1を指定します。
CameraのCullingMaskはMIrrorReflectionに、SizeとCilppingPlanesをそれぞれ0.5と Near -0.1、Far 0.1 に設定します。
そして、TargetTextureにRenderTexture2を指定します。
HDRやMSAAはOFFにしてください。
UdonBehaviourには先ほどコードを書いたUdonProgramを指定し、VideoPlayerには上で設定したビデオプレイヤーを、Camにはカメラを、そしてTexには作成した真っ白なTexture2Dを割り当てます。
URLには生成した動画を保存したアドレスを指定します。
最後に、ユーザー等が映り込まないように、このオブジェクトを遠くに移動させておくと良いでしょう。
ちょっと煩雑な手順が多いかもしれませんが、如何だったでしょうか。
今回はこれがどこまで実用に耐えうるのか、という試験を兼ねてVRCスクールという場で試させていただきました。
3期の期間中、エンコードエラー等が発生しなかったところをみるに、十分実用に耐えうる構造ではないかと思っています。
そう遠くないうちに、これを利用した新たなVRCスクロールイベントカレンダーを作成する予定です。今度のはイベント詳細が表示できるものを目指しています。安定板の開発にはそこそこ時間がかかるかもしれませんが、期待していただけると嬉しいです。諸事情によりVRCイベントカレンダーからは手を引くこととなりました。今までご利用いただいた皆様、ありがとうございました。
それでは、長々とお付き合いありがとうございました。
神城アオイ
2021/12/03(金) 12:27 (PST)
2022/12/07(水) 00:31 (JST) 更新