3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

VR内での便利なUIを作ってみる!1/3 スライドVCI解析編

Last updated at Posted at 2025-12-01

はじめに

みなさんは、pdfをアップロードするだけでVキャス内でスライド発表できる 「スライドVCI」 は使ったことありますか?
ポインタで、スライド内を指し示せるのは便利ですよね

しばらく前に、VCIの ダウンロード機能 が実装されたため、スライドVCIの中身を確認できるようになりました!
実は、こちらのスクリプトを流用すると、VR内動作するタブレット型のUI が簡単にできてしまうんです!
本シリーズは3部構成で、「スライドVCIの解析」、「スライドVCIからタブレットVCIの発展」、「ボタンやスライド操作の実装」の順に進んでいきます!

それでは、本記事にてスライドVCIの内部スクリプトを解析し、スライド上でポインタ位置を計算している仕組み を解説します!

このシリーズでわかること

  • スライドVCIの仕組み(←今回でわかること)
  • ペンが平面に対して指している場所の、ローカル座標の算出方法
  • タブレットの、ボタンの認識方法やスライダーの動作方法

1. スライドVCI解析編 ←今回
2. スライドVCIからタブレットVCIへの発展編
3. ボタンやスライド操作の実装編

スライドVCIの仕組みを見てみよう!

スクリプトは長いですが、ポイントは「ペンの向きから、どのようにスライド平面の座標を算出しているか」の部分なので、それがどの部分か見つけていきます

スライドVCIの中身(全部見る必要がないので畳んで省略)
main.lua
-- Generatorから設定される
MAX_PAGE = 9
PAGE_WIDTH = 2048
PAGE_HEIGHT = 1152
MAX_X_PAGE = 1
MAX_Y_PAGE = 1
-- ここまで

ga = PAGE_WIDTH / 2048
gb = PAGE_HEIGHT / 2048
gc = MAX_X_PAGE * MAX_Y_PAGE

gd = "SlideMain "
ge = "currentPage"

local gf = vci.assets.GetTransform("SubSlide")
local gg = vci.assets.GetTransform("Next")
local gh = vci.assets.GetTransform("Priv")

local gi = vci.assets.GetTransform("Pointer")

local gj = vci.assets.GetTransform("SubPointer")
local gk = vci.assets.GetTransform("StickOrigin")

local gl = vci.assets.GetTransform("MainOrigin")
local gm = vci.assets.GetTransform("MainT")
local gn = vci.assets.GetTransform("MainR")
local go = vci.assets.GetTransform("MainTR")

local gp = vci.assets.GetTransform("SubOrigin")
local gq = vci.assets.GetTransform("SubT")
local gr = vci.assets.GetTransform("SubR")
local gs = vci.assets.GetTransform("SubTR")

function g_a(currentPage)
    vci.state.Set(ge, currentPage)
end

function g_b()
    local la = vci.state.Get(ge)
    if la == nil then
        la = 0
    end
    return la
end

function g_c()
    local la = g_b()
    la = la + 1
    if la >= MAX_PAGE then
        la = 0
    end
    g_a(la)
end

function g_d()
    local la = g_b()
    la = la - 1
    if la < 0 then
        la = (MAX_PAGE - 1)
    end
    g_a(la)
end

function onUse(use)
    if use == "Next" or use == "Stick" then
        g_c()
    end
    if use == "Priv" then
        g_d()
    end
end

function updateAll()
    gk.SetLocalRotation(Quaternion.identity)

    local la = g_b()

    local lb = math.floor(la / gc) + 1

    for i = 1, 20 do
        local lc = vci.assets.GetTransform("Page_" .. i)
        local ld = vci.assets.GetTransform("SubPage_" .. i)
        lc.SetActive(false)
        ld.SetActive(false)
        if i == lb then
            lc.SetActive(true)
            ld.SetActive(true)
        end
    end

    local le = la % gc
    local lf = le % MAX_X_PAGE * ga
    local lg = (1 + math.floor(le / MAX_X_PAGE)) * gb
    vci.assets.material.SetTextureOffset(gd .. lb, Vector2.__new(lf, 1 - lg))

    local lh = gf.GetLocalScale()
    gg.SetLocalScale(lh)
    gh.SetLocalScale(lh)

    local li = g_e(gk, gp, gq, gr, gs)
    gj.SetActive(li.enable)
    gj.SetPosition(li.value)

    if li.enable == false then
        local lj = g_e(gk, gl, gm, gn, go)
        gi.SetActive(lj.enable)
        gi.SetPosition(lj.value)
        if lj.enable then
            gj.SetActive(true)
            gj.SetLocalPosition(gi.GetLocalPosition() * (3 / 10))
        end
    else
        gi.SetActive(true)
        gi.SetLocalPosition(gj.GetLocalPosition() * (10 / 3))
    end
end

function g_e(pointStart, origin, top, right, topRight)
    local la = origin.GetForward()
    local lb = origin.GetPosition()
    local lc = pointStart.GetPosition()
    local ld = pointStart.GetUp()
    local le = Vector3.Dot(la, lb);

    local lf = lc + ((le - Vector3.Dot(la, lc)) / (Vector3.Dot(la, ld))) * ld;

    local lg = origin.GetPosition()
    local lh = top.GetPosition()
    local li = topRight.GetPosition()
    local lj = right.GetPosition()
    local lk = lf

    local ll = lg - lk
    local ln = lh - lk
    local lm = li - lk
    local lo = lj - lk
    local lp = lh - lg
    local lq = li - lg

    local lr = Vector3.Cross(ll, ln).normalized
    local ls = Vector3.Cross(ln, lm).normalized
    local lt = Vector3.Cross(lm, lo).normalized
    local lu = Vector3.Cross(lo, ll).normalized
    local lv = Vector3.Cross(lp, lq).normalized

    local lw = {}

    if Vector3.Dot(lv, lr) > 0 and
        Vector3.Dot(lv, ls) > 0 and
        Vector3.Dot(lv, lt) > 0 and
        Vector3.Dot(lv, lu) > 0
    then
        lw.enable = true
        lw.value = lf
    else
        lw.enable = false
        lw.value = Vector3.zero
    end

    return lw
end

if vci.assets.IsMine then
    g_a(0)
end

中身を見ると、 g_e の関数で、ペンがタブレットを指している位置(ポインタを置くべき位置)を計算して、ポインタを表示するか・非表示にするかのフラグと、位置座標を返しているようです

function g_e(pointStart, origin, top, right, topRight)
    local la = origin.GetForward()
    local lb = origin.GetPosition()
    local lc = pointStart.GetPosition()
    local ld = pointStart.GetUp()
    local le = Vector3.Dot(la, lb);

    local lf = lc + ((le - Vector3.Dot(la, lc)) / (Vector3.Dot(la, ld))) * ld;

    local lg = origin.GetPosition()
    local lh = top.GetPosition()
    local li = topRight.GetPosition()
    local lj = right.GetPosition()
    local lk = lf

    local ll = lg - lk
    local ln = lh - lk
    local lm = li - lk
    local lo = lj - lk
    local lp = lh - lg
    local lq = li - lg

    local lr = Vector3.Cross(ll, ln).normalized
    local ls = Vector3.Cross(ln, lm).normalized
    local lt = Vector3.Cross(lm, lo).normalized
    local lu = Vector3.Cross(lo, ll).normalized
    local lv = Vector3.Cross(lp, lq).normalized

    local lw = {}

    if Vector3.Dot(lv, lr) > 0 and
        Vector3.Dot(lv, ls) > 0 and
        Vector3.Dot(lv, lt) > 0 and
        Vector3.Dot(lv, lu) > 0
    then
        lw.enable = true
        lw.value = lf
    else
        lw.enable = false
        lw.value = Vector3.zero
    end

    return lw
end

このg_eの関数の中身を見ていくと、ベクトルの内積や外積を用いて、計算しているようです
(ベクトルの内積と外積というと、高校の数学で習ったのを思い出しますね)
では、g_eの関数の中身を順々に見ていきましょう!

g_e関数の前半:ペンの延長線上とスライド平面の交点を求める

g_e関数の前半部分では、スライド平面上のペンの指している位置計算を行っています
前半部分は、以下の通りです

前半
local la = origin.GetForward()
local lb = origin.GetPosition()
local lc = pointStart.GetPosition()
local ld = pointStart.GetUp()
local le = Vector3.Dot(la, lb);

local lf = lc + ((le - Vector3.Dot(la, lc)) / (Vector3.Dot(la, ld))) * ld
  • la:スライドの法線ベクトル
  • lb:スライドの原点
  • lc:ペンの先端の点
  • ld:ペンの軸方向のベクトル

lalblcldは、上記のような点とベクトルです
そして、xをスライド平面上の点とした時、以下のような関係が成り立ちます

  • スライド平面上のベクトルx - lbと、スライドの法線ベクトルlaは直交する
  • ペンの軸先の延長線上に、xがある

以上の2つの条件から、次の2つの式が成り立ちます

la・(x - lb) = 0・・・ ①
x = lc + t・ld ・・・ ②
20251109_Figure1.png 20251109_Figure2.png

①を式変形すると以下のようになり、

la・x = la・lb・・・ ①'

①'に②を代入して、tについて解くと、以下のようになり、

t = (la・lb - la・lc) / (la・ld)

tを②に代入すると、lfと同じ式が出来上がり、lfはスライド平面とペンの延長線上の交点を示していることがわかりました

x = lc + (la・lb - la・lc) / (la・ld)・ld

この関数を使う上で重要な条件は次の2つです

重要なポイント
1. スライド原点(origin)のz軸が、スライド平面に垂直であること
2. ペン先の点(pointStart)は、ペンの軸に対してy軸を合わせて置くこと

前半部分の導出を理解できてなくても、この2点だけ守れば問題なく使用できます!
シンプルで便利な式ですね!

g_e関数の前半:スライド範囲内かどうかを判定する

後半部分は、交点lfがスライド平面の内部にいるかどうかの確認を行っています
後半部分は、以下の通りです

後半
local lg = origin.GetPosition()
local lh = top.GetPosition()
local li = topRight.GetPosition()
local lj = right.GetPosition()
local lk = lf

local ll = lg - lk
local ln = lh - lk
local lm = li - lk
local lo = lj - lk
local lp = lh - lg
local lq = li - lg

local lr = Vector3.Cross(ll, ln).normalized
local ls = Vector3.Cross(ln, lm).normalized
local lt = Vector3.Cross(lm, lo).normalized
local lu = Vector3.Cross(lo, ll).normalized
local lv = Vector3.Cross(lp, lq).normalized

local lw = {}

if Vector3.Dot(lv, lr) > 0 and
    Vector3.Dot(lv, ls) > 0 and
    Vector3.Dot(lv, lt) > 0 and
    Vector3.Dot(lv, lu) > 0
then
    lw.enable = true
    lw.value = lf
else
    lw.enable = false
    lw.value = Vector3.zero
end

順番に見ていきましょう
まず、この部分で、交点lf(スクリプト内では、lkに置き換えている)からのスライドの四隅へのベクトルll,ln,lm,loと、スライド平面のX軸Y軸ベクトルlp,lqを作ります

local ll = lg - lk
local ln = lh - lk
local lm = li - lk
local lo = lj - lk
local lp = lh - lg
local lq = li - lg
20251109_Figure3.png

続いて、交点lk(lf)から原点へのベクトルから時計回りに外積をとり、法線ベクトルlr,ls,lr,luと、スライド平面の法線ベクトルlvを作っていきます

local lr = Vector3.Cross(ll, ln).normalized
local ls = Vector3.Cross(ln, lm).normalized
local lt = Vector3.Cross(lm, lo).normalized
local lu = Vector3.Cross(lo, ll).normalized
local lv = Vector3.Cross(lp, lq).normalized

続いて、lvとぞれぞれの法線ベクトルlr,ls,lr,luで内積をとり、それぞれのベクトルがlvと同じ方向かどうかの判定を行い、スライド平面の法線ベクトルlvと同じであれば、スライドの内側に交点lk(lf)があるという判定を行っています

外積(Vector3.Cross)で「面の向き」、内積(Vector3.Dot)で「向きの一致」を判定しています
交点がスライド外部にあると、外積の右ネジの法則で、ベクトルが反転し、内積を計算した際に負の値となります

内積(Vector3.Dot(a,b))のa,bの順番は重要ではありませんが、外積(Vector3.Cross(a,b))のa,bの順番は重要です
必ず、外積をとる向きは、時計回りか、逆時計回りで統一しましょう

if Vector3.Dot(lv, lr) > 0 and
    Vector3.Dot(lv, ls) > 0 and
    Vector3.Dot(lv, lt) > 0 and
    Vector3.Dot(lv, lu) > 0
then

外積をとって、内積をとるだけで、スライド平面上の点がスライド範囲の内部にあるか外部にあるか判断できるなんて面白いですね!
ここで重要なのは、以下の一点だけです

重要なポイント
1. スライドの四隅の点lg,lh,li,ljを用意すること

最終的な戻り値

ここまでで、ペンの軸の延長線上とスライド平面上の交点の位置の計算と、その点がスライドの内側に点があるかどうかの判定ができたので、それを以下のように、lw.enableで内側の可否、lw.valueで位置情報を返してます

local lw = {}

if Vector3.Dot(lv, lr) > 0 and
    Vector3.Dot(lv, ls) > 0 and
    Vector3.Dot(lv, lt) > 0 and
    Vector3.Dot(lv, lu) > 0
then
    lw.enable = true
    lw.value = lf
else
    lw.enable = false
    lw.value = Vector3.zero
end

まとめ

今回、スライドVCIの仕組みについて紐解き、ペンの向きからスライド平面上のどこを指しているのか計算する方法を解説しました
この計算方法を扱う上で重要な点は以下の通りです

重要なポイント
1. スライド平面の四隅にEmptyObjectを置くこと
2. 四隅の左下のEmptyObjectは、ローカルZ軸をスライド平面と垂直になるようにすること
3. ペン先に、ペンの軸にY軸を合わてEmptyObjectを置くこと

20251109_Figure4.png

おわり

いかがでしたか?
難しそうに見えて、原理はシンプルで簡単なことがわかったのではないでしょうか?
「高校数学わからんー」っていう方も、上記の重要な点さえ押さえていれば、理解できなくともタブレット型UIを作れます!
挫けずに続いての記事をご覧になって、タブレット型UIにチャレンジしてみてくださいね!

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?