UnityなしでVCIの作成にチャレンジ!

9 min read読了の目安(約8600字

はじめに

VCIはバーチャルキャストで利用できるアイテムを作成するための仕様です。
通常はUnityで作りますが、ちょっとした変更でRubyを起動したり覚えるのは大変なのでWebアプリから生成できるようにRubyでVCIを書き換えれないかを試してみました。

とりあえず前回読み込みが出来たので書き換えてみよう、という話。プレゼンで使うスライドを今回は作っていきます。

https://zenn.dev/koduki/articles/7596fadeaff328

先に結論を書くと、書き換え自体は成功はしたのですが、未解決課題が残ってる状態。
oOさんがデバッグして解決してくれました! サンキューです。サンプルコードもアップデートしました。

今回のソースコードは下記となります。

https://github.com/koduki/vci-slide-generator/tree/a0.2

テンプレートVCI

まずは雛形となるVCIを読み込みます。今回はこちらを使ています。グリップするとページが切り替わるシンプルなものです。これはUnityで作りました。初めてのUnityなどでこれ作るの事態が自体が結構時間かかってしまいましたがw

https://seedonline.jp/items/a52e2c71ce74a9dc9f421ab0f53634bc361704890def137de06c64e23aaf2048

UniUnlitをOpaqueでレンダリングして、800x450の画像を横になれべ手連結した画像をテクスチャとして設定しています。Tilingの値を1 / ページ数に設定し、こちらの値を変更していくことでページの切り替えを実現。ほぼほぼVCIチュートリアルのサンプルを参考にさせて頂きました。

テンプレートの読み込みは以下の通り。

require 'json'

template_vci_path = ARGV[0] #'WhiteBoard.vci'
image_path = ARGV[1]  #'001.png'
page_size = ARGV[2].to_f
output_path = ARGV[3] #'dist/output.vci'
info = {title:ARGV[4], version:ARGV[5], author:ARGV[6], description:ARGV[7]}

GLB_H_SIZE = 4
GLB_H_MAGIC = "glTF".b
GLB_H_VERSION = [2].pack("L*")
GLB_JSON_TYPE = "JSON".b
GLB_BUFF_TYPE = "BIN\x00".b
FF = "\x00".b

#
# Load Template
#
io = open(template_vci_path)
glb_h_magic = io.read(GLB_H_SIZE)
glb_h_version = io.read(GLB_H_SIZE).unpack("L*")[0]
glb_h_length = io.read(GLB_H_SIZE).unpack("L*")[0]

glb_json_length = io.read(GLB_H_SIZE).unpack("L*")[0]
glb_json_type = io.read(GLB_H_SIZE)
glb_json_data = io.read(glb_json_length)

glb_buff_length = io.read(GLB_H_SIZE).unpack("L*")[0]
glb_buff_type = io.read(GLB_H_SIZE)
glb_buff_data = io.read(glb_buff_length)

property = JSON.parse(glb_json_data)

リソースの読み込み

今回のはスライドの画像を置き換えるので、画像とそれに対応するスクリプトを書き換えます。そのため、まずはそれらのリソースを読み込みます。

まずは画像から。合わせてimg_idxに書き換え対象の画像のbufferViewのインデックス値も入れて置きます。今回利用する画像はこちらです。

image = open(image_path, 'rb').read
img_idx = property["images"].find{|x| x["name"] == "template" }["bufferView"]

つづいてスクリプト。同じくsrc_idxに書き換え対象のスクリプトのbufferViewのインデックス値も入れて置きます。

src = <<"EOS"
GrabCount = 0
UseCount = 0
MAX_SLIDE_PAGE = #{page_size.to_i}

function onGrab(target)
    GrabCount = GrabCount + 1
    print("Grab : "..GrabCount)
    print(target)
end

function onUse(use)
    UseCount = UseCount + 1
    print("onUse : "..use..UseCount)

    local index = UseCount % MAX_SLIDE_PAGE
    local offset = Vector2.zero
    offset.y = 0
    offset.x = (1.0 / MAX_SLIDE_PAGE) * index
    vci.assets._ALL_SetMaterialTextureOffsetFromName("ScreenTexture", offset)
    print("page: "..(index + 1))
end
EOS
src_idx = property["extensions"]["VCAST_vci_embedded_script"]["scripts"][0]["source"]

スクリプトは今回はヒアドキュメントでそのままRubyコードの中に記載して変数展開でページ数を書き換えています。より本格的にやる場合は別ファイルにしてERB等で変数を代入するのが使いやすい気がします。

データ部の書き換え

次にデータ部(Chunk 1)の変更です。Rubyではバイナリを文字列と同様に処理して基本的には大丈夫です。差し替えるリソースの時以外は元の値をそのまま入れます。

def padding_size(data_size)
    if data_size == 0 then
        return 0
    else
        m = data_size % 4
        return m > 0 ? 4 - m : 0
    end
end

data = ""
property["bufferViews"].each_with_index do |x, i|
    case i
    when img_idx
        data += image + FF * padding_size(image.size)
    when src_idx
        data += src + FF * padding_size(src.size)
    else
        len = x["byteLength"]
        data += glb_buff_data[x["byteOffset"], len] + FF * padding_size(len)
    end
end

ポイントというか注意点がこのdiffでして、最初この値を設定してなかったのでThe Seed Onlineにアップロード自体は成功するのですがプレビューで失敗するという事象に見舞われていました。

エラーメッセージも出ずエラーの詳細が分からないので試行錯誤を繰り返してたのですが、VCIというか元フォーマットのglTFとして以下のような仕様があるようです。

https://github.com/KhronosGroup/glTF-Tutorials/blob/master/gltfTutorial/gltfTutorial_005_BuffersBufferViewsAccessors.md

つまり、Float:5126とかUNSIGNED_INT:5125 とかのデータサイズ、この場合は4バイトで割って、さらにその境界が4バイト区切りになるようにする必要があるようです。たしかにUnityが生成したVCIのbufferViewsを見てるとオフセット値や実際のバイナリで実際のデータ長以外の値が入ってるのが確認できました。
データ型に関しては詳しくはこちらを参照。

ただ、このデータ型の値が分からない。。。imageがつまり何バイトなのかを見つけることが出来なかったのですよね。なので試行錯誤の末、以下の値で良いことが分かりました。

4 - (image.size % 3)

4から引いてるのは4バイト区切りに合わせるためですが問題は3。この式で補正出来るのですがなぜここに3が入るのか良く分かりません。なんとなくpngの画像を変換したときRGB的な3要素になってるのかも? と思うんですが、確認はできておらず。。。詳しい人いたらぜひ教えてください!

こちらの値でゼロパディングしてやれば、無事にVCIをアップロードして動作させることが出来ます。

メタ情報の書き換え

ここらで息抜きにメタ情報の変更をします。

vci_meta = property["extensions"]["VCAST_vci_meta"]
vci_meta["title"] = info[:title]
vci_meta["version"] = info[:version]
vci_meta["author"] = info[:author]
vci_meta["description"] = info[:description]

特筆すべきことはありませんが、必要なプロパティを更新できます。今回は試してないですがライセンスやアイコンもここで変えることができるはずです。

続いて、テスクチャのスケールの変更をします。ページ数に合わせてこちらを変更しないと画像が分割されたり複数表示されて意図しない挙動になりますので忘れずに設定しましょう。

material = property["materials"].find{|x| x["name"] == "ScreenTexture"}
material["pbrMetallicRoughness"]["baseColorTexture"]["extensions"]["KHR_texture_transform"]["scale"] = [(1.0 / page_size).floor(5), 1]

Buffers/bufferViewsの書き換え

Buffers/bufferViewsは外部データにあたるデータ部のサイズやオフセット値が書き込まれた部分です。こちらに従ってglTF/VCIではバイナリの読み込みを行います。

まずはbufferViewsの書き換え対象のbyteLengthを先ほど読み込んだリソースのサイズに置き換えます。続いて、offset値は原則ひとつ前のbyteOffsetbyteLengthの和になるのでそちらで更新してやります。
ここでもポイントはパディング値の取り扱いです。先ほどゼロ埋めした数だけずらしてやる必要があります。単純にbyteOffsetbyteLengthの和にならないケースがあるので注意しましょう。偶々なのか出力時に調整してるのか分かりませんが、書き換え対象の画像以外は特に弄らなくてよかったのでそこだけ調整を入れています。

property["bufferViews"][img_idx]["byteLength"] = image.size
property["bufferViews"][src_idx]["byteLength"] = src.size
xs = property["bufferViews"]
(1..xs.size-1).each do |i|
    px = xs[i - 1]
    len = px["byteLength"]
    offset = px["byteOffset"] + len + padding_size(len)
    xs[i]["byteOffset"] = offset
end

最後にbuffersbyteLengthdata.sizeの値を格納しJSON文字列として書き出します。

property["buffers"][0]["byteLength"] = data.size
json = property.to_json.gsub('/', '\/')

Chunk書き込み時の4バイト境界のパディングの設定

先ほど、bufferViewsの時にもパディングを入れましたが、Chunkの書き込み時にもそれぞれパディングが必要なようです。JSON部はスペース(0x20)パディングで、データ部はゼロ(0x00)パディングです。

https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md#binary-gltf-layout

なので以下のようなコードを書きました。

paddingValue = json.size % 4
padding = (paddingValue > 0) ? 4 - paddingValue : 0;
json = json + (" " * padding) 

paddingValue = data.size % 4
padding = (paddingValue > 0) ? 4 - paddingValue : 0;
data = data + (FF * padding)

これで上手くいきそうなものですが上手く動きません! なぜかデータ部のサイズに応じてJSON部のpaddingに調整が必要 という凄まじく謎な状態になっています。
上記のコードでJSON側のサイズを変えても無事対応できるのですが、データ部のサイズを変えたときに上手くいかないのですよね。。。 なので、以下のようにしました。

paddingValue = json.size % 4
padding = (paddingValue > 0) ? 4 - paddingValue : 0;
padding += 3 # 謎の微調整
json = json + (" " * padding)

paddingValue = data.size % 4
padding = (paddingValue > 0) ? 4 - paddingValue : 0;
data = data + (FF * padding)

これで正常に動作するのですが、何故3なのかは全くの謎です. 今のところ計算式も不明なので画像を切り替えたときに0から3の値を入れてみてTSOにアップロードしてチェック とい方法でしか確認できてないです。パッとチェックした限りでは単純にdata.sizeの4バイトの余りとかではないので謎過ぎる。そもそも何故データ部の値に依存を。。。

下記のVキャス公式のコードを見てもそんな謎の微調整は無いので、基本的な所をミスってる可能性が高いのですがどなたかお気づきになりましたらご指摘いただけると非常に助かります

https://github.com/virtual-cast/VCI/blob/f7316a4e8c193605cacfa06fe0b0dc805d4fe212/Assets/VCI/VCIGLTF/Scripts/Format/glbTypes.cs#L115

oOさんがデバッグして解決してくれました! 原因として私がデータのアライメントを中途半端にやっていたので補正値で偶然値が合ったりズレたりしていたようです。下記のように適切にかけることで対応できました。ありがとうございます。

# Padding for 4 byte boundary
json_padding = padding_size(json.size)
json = json + (" " * json_padding)

data_padding = padding_size(data.size)
data = data + (FF * data_padding)

GLBに出力

データが準備できたのでGLBに出力します。読み込みと逆の手順なのでこれは簡単ですね。

glb = GLB_H_MAGIC
glb += GLB_H_VERSION
glb += [(GLB_H_SIZE * 3) + (GLB_H_SIZE * 2) + json.size + (GLB_H_SIZE * 2) + data.size].pack("L*")

glb += [json.size].pack("L*")
glb += GLB_JSON_TYPE
glb += json

glb += [data.size].pack("L*")
glb += GLB_BUFF_TYPE
glb += data

open(output_path, 'wb') do |f|
    f.write(glb)
end

最終的に出力したものはこちらに登録してあります。

https://seed.online/items/715f6601cae5c712504d40bdb1061059401472903d5e9a16698f31148bd210ab

まとめ

さて、無事にVCIをRubyで出力する事が出来きました。理論的にはUnity一切なしでも出来るのですが、それはツラすぎるのでやはり今回のようにテンプレートになるスクリプトをUnityで作って差し替える方式が無難だと思います。

読み込みが意外に簡単だったので、書き込みも楽勝でしょ! と思ってたらパディング問題に躓きだいぶ難航してしまいました。今も完了したとは言い難いし。

これをベースにWebサイトにPDFをアップロードしたら自動でVCIに変換してくれるサービスを作りたいのですが、まずはパディング問題の原因分析と解決ですかねぇ。
無事に解決して頂いたので、とりあえず早速でもアプリを作ってみたいと思います

現状Roomではスライド機能が使えないから、このVCI生成ツールが個人的に重宝しそうなので何とか完成させたいですね! 将来的にはTSOと連携できると良いなー。

それではHappy Hacking!