🔬

【VCI入門】スライドを変更するレーザポインタを作る

10 min read

この記事はVCI Advent Calendar 2021 15日目です。

はじめに

メタバースが話題になっている現在ですが、VirtualCastにはVCIという仕様でアイテムをルーム/スタジオ(所謂ワールド)を超えて持ち運ぶことが出来ます。相互運用のための仕組みですね。この記事はVCIを作る以下のチュートリアルの続きです。前回から大分間が開いてしまいましたが。。。

特に第一回はUnityのダウンロードからザ・シードオンラインへのアプロード、GitHubでコード管理を書いてるので 「Unity触ったことが無い!」 という方も取っつき易い記事になってるのではないかと思います。私がそうだったので私の苦労話が詰まってるはず。第三回の今回はスライドを切り替えるレーザポインタを作成しアイテム間の連携に関して書いていきたいと思います。

今回使ったコードはこちらとなります。

https://github.com/koduki/vci-slideboard

初心者による初心者のためのVCI入門というスタンスの記事なので、誤ってる点があればご指摘頂けると幸いです。

レーザポインタの仕様を考える

前回作ったホワイトボードVCIは画面をタッチすることで画像を切り替える事が出来ました。ただしスライドをめくるためには直接触る必要がありました。これだとちょっと使いづらいですよね? なので、今回は公式のホワイトボードと同じくレーザポインタで切り替えれるようにしていました。
完成品はこんな感じです。

https://seed.online/products/bcaf01edf83f0526682593cd160d5f36816c13c0faca41fc7fdfa1c421e0167d

仕様としては以下を考えます。

  • レーザっぽいものが伸びる
  • レーザポインタ側でUseをする事でスライドが切り替わる
  • レーザがスライドに当たる位置で進むだけでは無く、ページを戻すこともできる
  • 一つのレーザポインタで複数のスライドに対応できる

割と普通の要件ですね。ただ、地味に面倒なのが実は最後の項目です。これに関しては後ほど書きます。

レーザポインタ用のシーンの作成、オブジェクトの配置

まずは「New Scean」で以下のようにLazerPointer Sceanを作成します。VCIとして独立しているので別のProjectを新規に作成しても良いのですが、関連性が強いアイテムなので私は同じプロジェクト内に別のSceanとして作成しました。

空のオブジェクトを作成し、名前を変更。そしてVCIオブジェクトをコンポーネントとして紐づけます。

続いてレーザポインタのStickとLayzerをを作成します。StickはキューブでVCIサブアイテムを紐づけColliderはBoxColliderを使用します。重要なのはRigidbodyのMassの値を10000など十分に大きい値にすることです。後ほど書きますが、これをしないとレーザを出したときにVCIが揺れまくります。LazerもキューブでColliderはBoxColliderです。Rigidbody/Massは逆に1e-07と極小にします。

最後にStickの下にDummyLazerを作ります。これはレーザとスティックの動きを同期させるためのハックに必要になるので詳細は後ほど説明します。

レーザを点灯させるギミックを作る

概要

つづいて、レーザを点灯させるギミックを作ります。レーザーが点灯するというギミックを「どのようにVCIで表現するか?」ですが、今回は 「スティックにレーザという別のサブアイテムがくっついて動いてる」「レーザは表示/非表示が出来る」 というアイデアが基本になっています。これはLuaスクリプトで制御します。以下のようなコードです。

https://github.com/koduki/vci-slideboard/blob/master/Assets/MyAssets/Scripts/lazer_pointer.lua

それでは順を追ってみていきましょう。

レーザにスティックの動きを連動させる

VCIで複数オブジェクトを同期させるにはいくつかの方法があります。具体的には以下の3つです。

  • Unity上でオブジェクトを親子にする
  • Jointでつなげる
  • スクリプトで同期させる

それぞれメリット/デメリットがあり詳しくは下記の記事が非常に分かりやすいので要参照。今回は 「どのオブジェクトがuseされたか見分ける必要がある」 という点でスクリプトで同期させる方法を取っています。

https://qiita.com/lightjug/items/4a2be72369231c5f6da3

スクリプトは以下となります。連動させる振舞いをStickのサブアイテムであるDummyLazerにさせて、そことLazerを同期させてるわけですね。これにより親子関係にあるサブアイテムのように動きを同期させつつ、独立したサブアイテムとしてLayzerを扱う事が出来ます。

function updateAll()
    Lazer.obj.SetPosition(DummyLazer.GetPosition())
    Lazer.obj.SetRotation(DummyLazer.GetRotation())
    Lazer.obj.SetLocalScale(DummyLazer.GetLocalScale())
end

また単にVCIを連動させるだけだと、オブジェクトがめっちゃ暴れました。なので以下に記載があるようにRigidbody/Massの値を極端に重いのと軽いのにする事で、安定化させています。ただ、もしかしたら最終的にFixedJoinからスクリプトによる同期に変えたので、今の設定だともういらない対応かもしれないです。

https://alakialaca.hatenablog.com/entry/2019/08/04/200344

レーザの点灯(出し入れ)

次はレーザの点灯です。見た目だけでは無く当たり判定も無くしてしまわないといけないですよね。こちらはシンプルに以下のようなコードでレーザの長さ、つまりZ軸を変更しています。これによって伸縮みを実現しています。本当はレーザが別なVCI等に当たったら、そこまでの長さになるようにしたかったのですが、相手の座標を取る方法が上手くいかずとりあえずそれっぽい長さにする仕様にしました。レーザが物体にあたると貫通しますw

function TurnOnLazer()
    local scale = Vector3.__new(0.005, 0.005, Lazer.length)
    local pos = Vector3.__new(0, 0, Lazer.lightSource)
    DummyLazer.SetLocalPosition(pos)
    DummyLazer.SetLocalScale(scale)
end

function TurnOffLazer()
    local scale = Vector3.__new(0.005, 0.005, 0)
    local pos = Vector3.__new(Stick.getLocalPosition().x, Stick.getLocalPosition().y, 0)
    DummyLazer.SetLocalPosition(pos)
    DummyLazer.SetLocalScale(scale)
end

function onGrab(use)
    TurnOnLazer()
end

function onUngrab(use)
    TurnOffLazer()
end

スライドを切り替える

最後に本命のスライド切り替えです。VCIではVCI間の情報交換にメッセージを使う事が出来ます。

https://virtualcast.jp/wiki/vci/script/reference/message

onUseイベントの発生時にメッセージをsendFromLazerPointerという名前で送信します。これでレーザポインタ側の動きをスライド側で取得するわけですね。合わせてスライドVCI側にイベントを受信するvci.message.On("sendFromLazerPointer"...を追加します。これによってメッセージsendFromLazerPointerに反応してスライド側でアクションを起こすことが出来ます。

レイザーポインタVCI

function onUse()
    vci.message.Emit("sendFromLazerPointer", {event = BoardStatus, paringId = ParingId})
end

スライドVCI

function OnLazerPointerMessage(sender, name, message)
    print(string.format("sender ParingId:%s, own ParingId:%s, event:%s", message.paringId, ParingId, message.event))
    if (message.paringId == ParingId) then
        NextSlide(message.event)
    end
end
vci.message.On("sendFromLazerPointer", OnLazerPointerMessage)

続いてスライドの右側を指してるのか左側を指してるのかでスライドを進めるか戻すかを決めれるようにレーザポインタVCIのonTriggerEnterを変更します。onTriggerEnterはVCIとColliderが衝突したときに発生するイベントなので、レーザがスライドにあたった場合飲みを判定するようにif (item == "Lazer") を条件分岐にいれています。さらにその中で右側にぶつかったのか左側にぶつかったのかを判定するためにhitがLeftScreen/RightScreenのどちらの値か? を判定しています。

function onTriggerEnter(item, hit)
    if (item == "Lazer") then
        if (hit == "LeftScreen") then
            BoardStatus = "left"
            print(string.format("onTriggerEnter: item=%s, item=%s", item, hit))
        elseif (hit == "RightScreen") then
            BoardStatus = "right"
            print(string.format("onTriggerEnter: item=%s, item=%s", item, hit))
        elseif (hit == "Board") then
            local ms =
                math.floor(
                ((tonumber(string.match(tostring(os.clock()), "%d%.(%d+)")) * 1000000) / 1000000 / 1000000) + 0.5
            )
            ParingId = os.date("%Y%m%d%H%M%S") .. ms
            print(string.format("Slide Paring ID: %s", ParingId))
        end
    end
end

こちのスクリプトに対応するようにスライドVCI側に以下のようにRightScreen/LeftScreen, DummyRightScreen/DummyLeftScreenを作成します。これらは透明なオブジェクトで純粋に当たり判定をチェックするためだけに作っています。今回も親となるスライドと動きを連動させつつ別のサブアイテムとして判定させる必要があったのでDummyScreenを作ってスライドVCI側のスクリプトで対応付けています。

先ほどメッセージにbindさせたメソッドでは最終的にNextSlideを呼びます。こちらではstatusrightなのかleftなのかで挙動が変わるように条件分岐を入れてあります。

function NextSlide(boardStatus)
    if boardStatus == "right" then
        UseCount = UseCount + 1
        if (UseCount > (MAX_SLIDE_PAGE - 1)) then
            UseCount = 0
        end
        print("next")
    elseif boardStatus == "left" then
        UseCount = UseCount - 1
        if (UseCount < 0) then
            UseCount = (MAX_SLIDE_PAGE - 1)
        end
        print("prev")
    end

    local xidx = UseCount % MAX_SLIDE_X_INDEX
    local yidx = math.ceil((UseCount + 1) / MAX_SLIDE_X_INDEX) % MAX_SLIDE_Y_INDEX
    local width = (1.0 / MAX_SLIDE_X_INDEX)
    local height = (1.0 / MAX_SLIDE_Y_INDEX)
    local offset = Vector2.zero
    offset.y = 1 - height * yidx
    offset.x = width * xidx

    local page = UseCount % (MAX_SLIDE_X_INDEX * MAX_SLIDE_Y_INDEX) + 1
    vci.assets._ALL_SetMaterialTextureOffsetFromName("Slide", offset)
end

これでいったんレーザポインタでスライドを切り替える事が出来る用になりました。

スライドとレーザポインタのぺリング

実はこの時点ではスライドとレーザポインタのペアリングが出来ていないので複数のスライドを出しておくと、どれか一つにあたってる場合全部のスライドが同時に操作されてしまいます。
これはちょっと微妙なので、きちんとレーザポインタが当たってるスライドのページだけがめくれる形に変更します。

ペアリングの方式は色々ありそうです。まず考えたのはEmitWithIdを使いVciIdを送信することです。しかし、そもそもVciIdを特定の相手に伝える方法が良く分かりませんでした。

https://virtualcast.jp/wiki/vci/script/reference/message

という分けで別案としてレーザとスライドがぶつかった際の時刻をペアリングIDとして使用することで擬似的な同期を作りました。レーザとスライドがぶつかった際にはonTriggerEnterがどちらにも発生するはずです。ここでタイムスタンプを取れば概ね一致するはずですよね? これをIDとしてお互いを判別することでペアリングIDとしています。精度とか色々課題はある方式ですが、今回はそこまで厳密性はいらないので。

タイムスタンプの取得は以下のようなコードです。四捨五入で多少誤差を丸めています。

    local ms =
        math.floor(((tonumber(string.match(tostring(os.clock()), "%d%.(%d+)")) * 1000000) / 1000000 / 1000000) + 0.5)
    ParingId = os.date("%Y%m%d%H%M%S") .. ms

レーザポインタ側からのメッセージの送信時は以下のようにparingIdも送信します。

function onUse()
    vci.message.Emit("sendFromLazerPointer", {event = BoardStatus, paringId = ParingId})
end

また、スライド側でレーザポインタからメッセージを受け取った場合は以下のようにParingIdを比較し、スライド側とレーザポインタ側が同一であればNextSlideを呼んでいます。

function OnLazerPointerMessage(sender, name, message)
    print(string.format("sender ParingId:%s, own ParingId:%s, event:%s", message.paringId, ParingId, message.event))
    if (message.paringId == ParingId) then
        NextSlide(message.event)
    end
end
vci.message.On("sendFromLazerPointer", OnLazerPointerMessage)

これで複数のスライドを出した状態でもレーザポインタを当てたスライドだけ切り替わる、という挙動を実現する事ができます。レーザポインタとスライドのスクリプトの全体は下記にあるので良ければそちらも参考にしてください。

まとめ

第一回、第二回、第三回と通してUnityのインストールの仕方からLuaスクリプトの作り方、そして複数のVCIの協調までを行いました。正直なところ、こうしたチュートリアルを書くことで私自身の理解を進める、という側面もあるので至らないところもあると思いますが、そこはご指摘いただければと。

Vキャスの大きな魅力はやはりVCIだと思います。アイテムがワールド側に結びついておらず、自由に持ち運べる事で様々なことができます。作成も比較的かんたんなので3Dは苦手ですが自分ももっと色々作ってみたいと思います。

Discussion

ログインするとコメントできます