この記事はVR法人HIKKYのアドベントカレンダー16日目の記事です。
はじめに
こんにちは。HIKKYのしらピーです。
Vket Cloudを使ったコンテンツ制作やコンテンツの監修の業務を行っております。
弊社開発のWebブラウザ上で動作するVRエンジン「Vket Cloud」は2025年11月、バージョン16にアップデートし、ワールドの機能制作に便利な新しい開発機能が増えました。
本記事ではバージョン16にてHeliScript 1 にひっそりと追加された、「物理演算制御メソッド」について紹介します。
先日投稿したローカルトランスフォームメソッドの記事にもサッカーのサンプルで少しだけ登場しましたが、改めて物理演算ギミックにフォーカスを充てて紹介します。
Vket Cloudにおける物理演算
Vket Cloudの物理演算機能自体は2023年夏ごろから存在します。

↑公式ワールド「テクノカインド」より、物理演算を用いた缶蹴りギミック
公式マニュアルでは以下のページに、
Web記事では以下のページの中間あたりにある「物理演算も使える!」で紹介がされています。
しかし、長らくの間物理演算を扱うための機能は「アクション」に集約された状態になっていました。

↑アクションを使うことでノーコーディングでギミックを実装できますが、固定値しか入力できないデメリットがあります。
この度、バージョン16アップデートにて物理演算機能をHeliScriptから扱えるようになりました。
これにより、従来では出来なかった可変値を用いた物理演算ギミックを作れるようになりました。
本題の前に - 物理エンジンを有効化する設定について
Vket Cloudのワールドで物理演算を扱うために必要な設定を紹介します。
-
Inspectorビューに表示されるBase Setting コンポーネントの中にある「物理エンジン」にチェックを入れます。

-
各ノードのVKC Node Collider Unityコンポーネントにある、「物理演算を適用」にチェックを入れます。

※VKC Node Collider Unityコンポーネントは初回ビルド時に自動で付与されます。
※手動で付けても構いません。
床や壁などの動かないノードには、「位置の固定」にもチェックを入れましょう。
チェックが入っていない場合、プレイヤーが上に乗ることができず、重力に従って落ちてしまいます。
以上の設定を行うことで、ワールドで物理演算が使えるようになります。
サンプル
スクリプトを使うことで好きなタイミングで、好きな方向に、好きな力を加えることができるようになります。
それを最大限活かせるサンプルとして、簡易的なビリヤードを作ってみました。
※画像をクリックするとワールドが立ち上がります。
- キュー(棒)をクリックすると、白い球に向かって飛んでいきます。
- プレイヤーが移動すると、キューが追従します。
- プレイヤーが白い球から離れれば離れるほど、強いショットになります。
- ボールが動き始めてから10秒経過で動いているボールがすべて止まり、再度ショット前に戻ります。
※ボールが動いている間、プレイヤーはボールに干渉することができます。壁に押し寄せることで場外に弾き飛ばすこともできますが簡易的なサンプルなので悪しからず。。
※複数人が同時に空間に入室することはできますが、ボールの位置等の同期は行っていないため、人によってボールの位置が異なります。
スクリプト(フィールド側)
// ビリヤード場のスクリプト
component Billiards
{
Item _self; // 自分自身のアイテム情報
float _timer; // ショットからの経過時間
bool _isMoving; // ボールが動いているかどうか
// ノードの固有名称
const string _BilliardBallMain = "main";
const string _BilliardBall1 = "1";
const string _BilliardBall2 = "2";
const string _BilliardBall3 = "3";
const string _BilliardBall4 = "4";
const string _BilliardBall5 = "5";
const string _BilliardBall6 = "6";
const float _TimeLimit = 10.0; // ボールが動く時間制限
// コンストラクタ(ワールド入室直後)
public Billiards()
{
_self = hsItemGetSelf(); // 自分自身のアイテム情報を登録
// shotイベントを受け取った際、ビリヤード開始関数を発火
hsAddEventListener("shot", StartBilliard);
SetBallPhysics(false); // ボールの物理演算を無効化
}
// 毎フレーム実行関数
public void Update()
{
// 動いている時、経過時間を加算
if(_isMoving){
_timer += hsSystemGetDeltaTime();
// 経過時間が制限時間以上になったら終了
if(_timer >= _TimeLimit){
EndBilliard();
}
}
}
// ビリヤード開始(引数:ボールにかかる力の情報)
void StartBilliard(string force){
if(_isMoving){return;} //すでに動いている場合、何もしない
// ボールにかかる力の情報をx,y,zに分けてVector3変数に格納(この時100倍する)
list<string> forcelist = force.Split(",");
Vector3 forceVec3 = makeVector3( forcelist[0].ToFloat()*100, forcelist[1].ToFloat()*100, forcelist[2].ToFloat()*100);
// 各ボールの物理演算を有効化
SetBallPhysics(true);
// 白球に力を加える
_self.AddPhysicsWorldForce(_BilliardBallMain, forceVec3);
_isMoving = true; // 動いているフラグを有効化
}
// ビリヤード終了
void EndBilliard(){
ClearBallForce(); // 各ボールにかかっている力を無効化
SetBallPhysics(false); // ボールの物理演算を無効化
_isMoving = false; // ボールが動いているフラグを無効化
_timer = 0; // 経過時間を初期化
hsDispatchEvent("end", ""); // 終了イベントを発火
}
// 各ボールの物理演算を引数で指定した状態に変更
void SetBallPhysics(bool flag){
_self.SetPhysicsEnable(_BilliardBallMain, flag);
_self.SetPhysicsEnable(_BilliardBall1, flag);
_self.SetPhysicsEnable(_BilliardBall2, flag);
_self.SetPhysicsEnable(_BilliardBall3, flag);
_self.SetPhysicsEnable(_BilliardBall4, flag);
_self.SetPhysicsEnable(_BilliardBall5, flag);
_self.SetPhysicsEnable(_BilliardBall6, flag);
}
// ボールにかかる物理演算の無効化
void ClearBallForce(){
_self.ClearPhysicsWorldForce(_BilliardBallMain);
_self.ClearPhysicsWorldForce(_BilliardBall1);
_self.ClearPhysicsWorldForce(_BilliardBall2);
_self.ClearPhysicsWorldForce(_BilliardBall3);
_self.ClearPhysicsWorldForce(_BilliardBall4);
_self.ClearPhysicsWorldForce(_BilliardBall5);
_self.ClearPhysicsWorldForce(_BilliardBall6);
// 空中に浮いたボールを地面に移動させる処理
SetBallHeightOnGround(_BilliardBallMain);
SetBallHeightOnGround(_BilliardBall1);
SetBallHeightOnGround(_BilliardBall2);
SetBallHeightOnGround(_BilliardBall3);
SetBallHeightOnGround(_BilliardBall4);
SetBallHeightOnGround(_BilliardBall5);
SetBallHeightOnGround(_BilliardBall6);
}
// 空中に浮いたボールを地面に移動させる処理
void SetBallHeightOnGround(string BallName){
// ボールの座標を取得
Vector3 ballPos;
ballPos = _self.GetNodeLocalPos(BallName);
// Y座標を0.5にしてボールに適用
ballPos.y = 0.5;
_self.SetPhysicsWorldPos(BallName, ballPos);
}
}
スクリプト(キュー側)
//キュー(棒)のスクリプト
component BilliardStick
{
// 自身とビリヤード場のアイテム情報
Item _self, _billiard;
// ロードが完了したかのフラグ、アニメーション中かどうかのフラグ
bool _isLoaded, _isAnimate;
// プレイヤーの座標、白球の位置、発射力
Vector3 _playerPos, _targetPos, _force;
// アニメーションタイマー
float _animTimer;
// アニメーションする時間の固定値
const float _AmimTime = 0.1;
public BilliardStick()
{
// 自信とビリヤード場のアイテム情報格納
_self = hsItemGetSelf();
_billiard = hsItemGet("World");
// ビリヤード場から一連の動作が終わったことを受信する
hsAddEventListener("end", ShowStick);
// 白球の初期座標を格納
_targetPos = _billiard.GetNodeLocalPos("main");
}
// ロード完了時コールバックでロード完了フラグを立てる
void OnLoaded(){
_isLoaded = true;
}
public void Update()
{
// ロードが完了するまで実行しない
if(!_isLoaded){return;}
// アニメーション中でない時、キューはプレイヤーを追従する
if(!_isAnimate){
// プレイヤーの座標を取得
_playerPos = hsPlayerGet().GetPos();
// 発射力は自身とプレイヤーの座標の差分
_force = _self.GetPos();
_force.Sub(_playerPos);
// 棒を自身と白球の位置関係を基に適切な方向に向かせる
_self.SetQuaternion(makeQuaternionYRotation(hsMathAtan2(_targetPos.x - _playerPos.x, _targetPos.z - _playerPos.z)));
// 棒のローカルZ座標を発射力を基に変更する(強ければ強い程後ろに下げる)
_self.SetNodeLocalPos("Cube", makeVector3(0,0,-hsMathSqrt(_force.x * _force.x + _force.y * _force.y + _force.z * _force.z) - 1.5));
// アニメーションタイマーを0にする
_animTimer = 0;
}
// アニメーション中の場合
else
{
// アニメーションタイマーを加算する
_animTimer += hsSystemGetDeltaTime();
// 棒のローカルZ座標を0.1秒(=アニメーション時間)で(0,0,-1.5)に等速直線移動
// これにより、白球に向かってキューが飛んでいくような動きになる
_self.SetNodeLocalPos("Cube", makeVector3(0,0,-hsMathSqrt(_force.x * _force.x + _force.y * _force.y + _force.z * _force.z) * ((_AmimTime - _animTimer) / _AmimTime) - 1.5));
// アニメーションタイマーがアニメーション時間を超えた際、棒を非表示、
// アニメーションフラグを折る、shotイベントを発火し発射力の情報を渡す
if(_animTimer > _AmimTime){
_self.SetShow(false);
_isAnimate = false;
hsDispatchEvent("shot", "%f,%f,%f" % _force.x % _force.y % _force.z);
}
}
}
// キューがクリックされたらアニメーションを開始する
bool OnClickNode(string NodeName){
if(!_self.IsShow() && _isAnimate){return false;}
_isAnimate = true;
return true;
}
// endイベント発火時に白球の位置を更新し、キューを表示する
void ShowStick(string dummy){
_targetPos = _billiard.GetNodeLocalPos("main");
_targetPos.y = 0.5;
_self.SetPos(_targetPos);
_self.SetShow(true);
}
}
Vket Cloud SDK実装解説
1.キュー(棒)について
キューは以下の画像のオブジェクト群をフィールドのエクスポートで.heoファイルに書き出したものを使用しています。
Cube部分が本体で、Scale(x,y,z) = (0.1,0.1,3)のCubeを使用しています。
VKC Node Collider Unityコンポーネントを持たせ、コライダータイプを「クリック対象」にすることで、プレイヤーをすり抜けるBoxColliderのクリック判定にしています。
書き出したものをVKC Item Object Unityコンポーネントで実装しています。
画像の通り、キュー用のスクリプトをVKC Attribute Script Unityコンポーネントで実装しています。
2.フィールドについて
ビリヤード場、ボール(、背景の天球)を含むフィールドは以下の画像のように実装されています。
見えない壁がステージを取り囲むようにBoxColliderで実装されています。
これによって、ボールがステージ端で反射し、プレイヤーがステージ外に出られないようになっています。
見えない壁はコライダーターゲットが「プレイヤー自身のみ」に設定されています。
これにより、カメラがコライダーにぶつからず、自由な画角でプレイすることが可能になっています。
また、VKC Item Fieldにて重要な設定を施しています。
それは、「Raycast判定の強制無効化」です。
物理演算はBoxCollider、SphereCollider、CupsuleColliderをCylinderとして扱う、の3種類でしか扱うことができず、BoxColliderはRaycast判定を吸収する効果があるため、Raycast判定の強制無効化の設定を行わなかった場合、キューをクリックしようとしても見えない壁のBoxColliderに阻まれてしまいます。
Raycast判定の強制無効化を使用することで、クリック判定を阻害しない物理演算対応の見えない壁を作ることが可能になります。
また、上記の画像の通りスクリプトをVKC Attribute Script Unityコンポーネントで実装しています。
入力値がキューと異なることにも注目です。
メソッド紹介
物理演算の制御に使うメソッドを紹介します。
いずれのメソッドも対象となるノードの物理演算が有効になっている必要があります。
Item.SetPhysicsEnable(string, bool)
第1引数で指定した名称のノードの物理演算の有効無効を第2引数で指定したものにします。
Item.SetPhysicsWorldPos(string, Vector3)
第1引数で指定した名称のノードを第2引数で指定したワールド座標に移動させます。
Item.SetPhysicsWorldRotation(string, Vector3)
第1引数で指定した名称のノードの回転を第2引数で指定したオイラー角に変更します。
Item.ClearPhysicsWorldForce(string)
指定した名称のノードにかかっている力をすべて0にします。
Item.AddPhysicsWorldForce(string, Vector3)
第1引数で指定した名称のノードに第2引数で指定した力を加えます。
Item.AddPhysicsWorldVelocity(string, Vector3)
第1引数で指定した名称のノードに第2引数で指定した速度を加えます。
サンプル実装解説
サンプルのビリヤードの実装部分の解説です。
ビリヤードの一連の流れは以下の通りです。
一連の流れ
- プレイヤーが移動し、キューの位置を決める
- キューを起動しショット、力の強さと方向は白球との位置関係で決まる
- 球が物理演算によって動く
- 一定時間経過で物理演算を停止、空中に浮いたボールを地面に戻して1に戻る
以降、この一連の流れに沿って、各フェーズでどんな関数を使って何をしているかを解説します。
1. プレイヤーが移動し、キューの位置を決める
キュー側のスクリプトのUpdate()関数内でプレイヤーの座標と白球の座標を取得し、その位置関係によってキューの向きや位置を決めます。
プレイヤーの座標取得にはPlayer.GetPos()を使用します。
白球の座標取得には、Item.GetNodeLocalPos(string)を使用します。
これは、バージョン16で追加されたローカルトランスフォームメソッドのひとつで、対象のノードの相対座標を取得することができます。
白球の親オブジェクトのTransformはPosition、Rotation、Scaleの順に(0,0,0)、(0,0,0)、(1,1,1)なので、相対座標と絶対座標が一致するため、取得値をそのまま用いることができます。
※ローカルトランスフォームメソッドについては以下の記事をご確認ください。
キューの向きはItem.SetQuaternion(Quaternion)を使用して設定しています。
キューが常に白球の方向に向くようにする必要があるため、QuaternionはmakeQuaternionYRotation(hsMathAtan2(_targetPos.x - _playerPos.x, _targetPos.z - _playerPos.z))を使って白球とプレイヤーの位置関係から適切な角度に向けるようにしています。
また、キューの位置はItem.GetNodeLocalPos(string,Vector3)を使って設定しています。
こちらも、ローカルトランスフォームメソッドになります。
キューのアイテムが持つ本体(Cube)に対し、ショットの力に応じて白球から遠ざける設定にしています。
また、キューのアイテム本体自体は白球と同じ位置に設定しています。
これにより、
- キューの位置は白球を中心にしている
- プレイヤーと白球の距離によって決まるショットの強さに応じてキューと白球の距離が決まる
- キューが常に白球の方向を向くようにする
を実現することができ、白球に加わる力の強さと向きが体感的にわかりやすい表示を実現しています。
2. キューを起動しショット、力の強さと方向は白球との位置関係で決まる
この仕組みを実装するにあたり、スクリプト同士を連携させるためにイベントリスナーを使用しています。
イベントリスナーについては以下の記事に書いてあります。
「キューがクリックされた時」を検知するために、OnClickNode(string)コールバックを使用しています。
これはHeliScript標準のコールバック関数で、「ノードがクリックされたとき」に実行されます。
キューがクリックされた時、キューが白球に向かって飛んでいくアニメーションが再生されます。
1同様、キューの位置はItem.GetNodeLocalPos(string,Vector3)を使って実装していますが、Vector3でタイマーの数値を用いることで「キューが白球に向かって移動する」を実現しています。
アニメーション終了後、キューのアイテムを非表示にすると同時に"shot"イベントを発火しています。
フィールド側スクリプトでは"shot"イベントに対するリスナーが設定されており、shotイベントが発火された時にStartBilliard(string force)メソッドが実行されるように設定しています。
このメソッドでは、ボールの物理演算を有効にし、白球にメソッド引数で指定された力を加える処理を行っています。
ここで、物理演算の有効化のためにItem.SetPhysicsEnable(string, bool)が、白球に力を加えるためにItem.AddPhysicsWorldForce(string, Vector3)が用いられています。
※キューが白球に衝突して動かしているように見えますが、キュー自体は物理演算判定を持っておらず、アニメーションにより衝突しているように見せかけているだけです。
3. 球が物理演算によって動く
2にて、各球の物理演算が有効化されているため、球が動きます。
4. 一定時間経過で物理演算を停止、空中に浮いたボールを地面に戻して1に戻る
ビリヤード場側スクリプトで一定時間をカウントし、それを超過した場合、終了メソッドEndBilliard()を実行します。
このメソッドでは、
- 各球にかかる力をリセットする
- 宙に浮いた球の座標を地上に設定し、物理演算を無効化
- 時間計測タイマー、現在動いているフラグのリセットを行う
- "end"イベントを発火する
の4つを行っています。
「各球にかかる力のリセット」では、Item.ClearPhysicsWorldForce(string)を使用しています。
これをしなかった場合、次に球の物理演算を有効化した際にもともとかかっていた力が加わってしまいます。
「宙に浮いた球の座標を地上に設定」では、Item.SetPhysicsWorldPos(string, Vector3)を使用してY座標を地上の高さ(=0.5)に設定しています。
「物理演算の無効化」では、Item.SetPhysicsEnable(string, bool)を使用しています。
「"end"イベントの発火」は2で紹介した"shot"イベントと同様のイベントリスナーを使用しています。
今度は、ビリヤード場側スクリプト → キュー側スクリプトの連携となります。
キュー側スクリプトでは、"end"イベントが発火された際、キューのアイテム本体の位置を白球の位置に設定し、キューを表示しています。
以上の実装で、ビリヤードの挙動を実現しています。
まとめ
物理演算をHeliScript上から扱えるようになったことで、好きなタイミングで可変の力を加えることができるようになり、物理演算を用いたギミックの自由度が向上しました。
イベントリスナーやローカルトランスフォームメソッドと組み合わせることで、高度なギミックも実装が可能となります。
「プレイヤーが物理演算の影響を受けて移動したりすることはない」、「物理演算中の物体の上に乗ることができない」、「物理演算で用いることのできるコライダーの種類に制限がある」など、Vket Cloudの物理演算は制約がありますが、スクリプト制御により出来ることが格段に増えたため、物理演算を用いたギミックワールド作りにチャレンジしてみてはいかがでしょうか?
おわりに
Vket Cloudは公式Discordサーバーがあります。
「こういったワールドを作りたいけどどうしたらいいか分からない」、「制作中にこんなエラーが出た!解決方法を教えてほしい」といった、ワールド制作の不明点はこちらのサーバーの質問・要望・不具合報告-forumに投稿していただけると、スタッフが解決方法や実装方法について回答します。お気軽にどうぞ!









