✈️

cluster に飛行機を実装する【2022年最新版】

2022/12/03に公開

この記事は クラスター Advent Calendar 2022 2日めの記事です。
昨日は htomine さんのパーフェクトシンク対応人間になって仮想空間でナルシストになろう でした。私はまだ非対応人間です。

こんにちは、クラスター株式会社で Engineering Manager をやっているねおりんです。今年のハイライトは、EMになったり、『スクリプト』などの開発 の project lead をしたりといった感じです。

昨年の Advent Calendar では、 Cluster Creator Kit でそれっぽい飛行機を実装する という記事を書きました。
今年はスクリプトもあるので、 クラフトアイテム として飛行機を作ってみます。

アイテムのセットアップ

まずはこんな感じで prefab をセットアップしてクラフトアイテムとしてアップロードします。

飛行機と言っても、乗り物の操縦UIはまだクラフトアイテムからは使えないので、今回は「紙飛行機を飛ばす」ことを目標とします。よって、 Grabbable Item として作ります。

紙飛行機の3Dモデルには https://3d-model.booth.pm/items/2017699 を使わせていただいています。

スクリプト

それでは早速 スクリプトを書いていきます

等速直線運動

クラフトアイテムにはまだ物理挙動は設定できないので、スクリプトで実装していきます。

持っている紙飛行機を離したとき、機体の正面方向に力を加え、毎フレーム位置を更新します。

const vector3Zero = new Vector3(0, 0, 0);

$.onGrab(isGrab => {
  if (!isGrab) {
    $.state.velocity = new Vector3(0, 0, 10).applyQuaternion($.getRotation());
  }
  $.state.isGrab = isGrab;
});

$.onUpdate(deltaTime => {
  const pos = $.getPosition();
  const isGrab = $.state.isGrab;

  if (isGrab) {
    return;
  }

  const velocity = $.state.velocity ?? vector3Zero;
  const newPos = pos.clone().add(velocity.clone().multiplyScalar(deltaTime));
  $.setPosition(newPos);
});

重力

このままでは無限の彼方まで飛んでいってしまうので、重力を実装します。

重力を定数で定義し、 $.onUpdate の中身を次のように書き換えます。

const gravity = new Vector3(0, -9.8, 0);

$.onUpdate(deltaTime => {
  const pos = $.getPosition();
  const velocity = $.state.velocity ?? vector3Zero;
  const newVelocity = velocity.clone()
    .add(gravity.clone().multiplyScalar(deltaTime));
  $.state.velocity = newVelocity;
  const newPos = pos.clone().add(newVelocity.clone().multiplyScalar(deltaTime));
  $.setPosition(newPos);
});

衝突

このままだとアイテムが地面をすり抜けて despawn してしまいます。

衝突はいまのスクリプトでは検知できないので、とりあえず y: 0 より下に行かないようにします。ついでに、地面についたときに速度を0にします。

$.onUpdate の中身は次のようになります。

$.onUpdate(deltaTime => {
  const pos = $.getPosition();
  const velocity = $.state.velocity ?? vector3Zero;
  const newVelocity = velocity.clone()
    .add(gravity.clone().multiplyScalar(deltaTime));
  $.state.velocity = newVelocity;
  const newPos = pos.clone().add(newVelocity.clone().multiplyScalar(deltaTime));
  const boundedNewPos = new Vector3(newPos.x, Math.max(newPos.y, 0), newPos.z);
  $.setPosition(boundedNewPos);
  if (newPos.y < 0) {
    $.state.velocity = vector3Zero;
  }
});

揚力

ここから飛行機らしい動きにしていきます。

揚力は空気密度、翼面積、速度、揚力係数から計算します。揚力係数は、簡易的に迎角と定数で計算する方法を採用しました。揚力ベクトルは機体に対して直角ではなく、気流 (進行方向) に対して直角になるのがポイントです。
揚力の実装に関して詳しくは、参考に記載のページも参照してみてください。

const vector3Up = new Vector3(0, 1, 0);
const mathRad2Deg = 180 / Math.PI;

const airDensity = 1.293; // 空気密度
const wingArea = 0.4 * 0.3; // 翼面積

// 揚力計算 (あとで抗力でも使う)
const calcLiftOrDrag = (coefficient, surface, velocity) => {
  return 0.5 * airDensity * (velocity * velocity) * surface * coefficient;
}

$.onUpdate(deltaTime => {
  const pos = $.getPosition();
  const rot = $.getRotation();
  const velocity = $.state.velocity ?? vector3Zero;
  const length = velocity.length();

  // 機体 local space の velocity から迎角を計算
  const localVelocity = velocity.clone().applyQuaternion(rot.clone().invert());
  const aoa = -Math.atan2(localVelocity.y, localVelocity.z) * mathRad2Deg;

  const liftFactor = calcLiftOrDrag(aoa, wingArea, length);
  
  // 揚力ベクトル
  // 今回は後ろに進むことはほとんど考慮しなくてもよさそう (外積1回でもよさそう) だけど一応
  const liftVector = velocity.clone()
    .cross(vector3Up.clone().applyQuaternion(rot))
    .cross(velocity)
    .normalize();

  const newVelocity = velocity.clone()
    .add(liftVector.clone().multiplyScalar(liftFactor * deltaTime))
    .add(gravity.clone().multiplyScalar(deltaTime));
  // 略
});

抗力

揚力は空気密度、翼面積、速度、抗力係数から計算します。係数が異なるだけで計算式は揚力と同じなので、先程の関数を使いまわします。抗力係数の近似計算式は参考実装のものを使わせていただいています。

$.onUpdate(deltaTime => {
  // 略
  // https://sites.google.com/view/ronsu900/createfs/wing1#h.79gl52y8ytmb
  const cd = Math.min(Math.max(0.005 + Math.pow(Math.abs(aoa) * 0.0315, 5), 0), 1);
  const drag = calcLiftOrDrag(cd, wingArea, length);
  const dragVector = velocity.clone().normalize().multiplyScalar(-1);

  const newVelocity = velocity.clone()
    .add(liftVector.clone().multiplyScalar(liftFactor * deltaTime))
    .add(dragVector.clone().multiplyScalar(drag * deltaTime))
    .add(gravity.clone().multiplyScalar(deltaTime));
  // 略
});

機体の姿勢

機体には進行方向を向こうとする力が働くのでそれを再現します。

進行方向の vector を向くような回転がほしいので、 UnityEngine でいうところの Quaternion.LookRotation() を実装します。

// https://answers.unity.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
const lookRotation = (forward, up) => {
  forward = forward.clone().normalize();
  const right = up.clone().cross(forward).normalize();
  up = forward.clone().cross(right);

  const m00 = right.x;
  const m01 = right.y;
  const m02 = right.z;
  const m10 = up.x;
  const m11 = up.y;
  const m12 = up.z;
  const m20 = forward.x;
  const m21 = forward.y;
  const m22 = forward.z;

  const num8 = (m00 + m11) + m22;
  if (num8 > 0)
  {
    var num = Math.sqrt(num8 + 1);
    const w = num * 0.5;
    num = 0.5 / num;
    const x = (m12 - m21) * num;
    const y = (m20 - m02) * num;
    const z = (m01 - m10) * num;
    return new Quaternion(x, y, z, w);
  }
  if ((m00 >= m11) && (m00 >= m22))
  {
    var num7 = Math.sqrt(((1 + m00) - m11) - m22);
    var num4 = 0.5 / num7;
    const x = 0.5 * num7;
    const y = (m01 + m10) * num4;
    const z = (m02 + m20) * num4;
    const w = (m12 - m21) * num4;
    return new Quaternion(x, y, z, w);
  }
  if (m11 > m22)
  {
    var num6 = Math.sqrt(((1 + m11) - m00) - m22);
    var num3 = 0.5 / num6;
    const x = (m10 + m01) * num3;
    const y = 0.5 * num6;
    const z = (m21 + m12) * num3;
    const w = (m20 - m02) * num3;
    return new Quaternion(x, y, z, w);
  }
  var num5 = Math.sqrt(((1 + m22) - m00) - m11);
  var num2 = 0.5 / num5;
  const x = (m20 + m02) * num2;
  const y = (m21 + m12) * num2;
  const z = 0.5 * num5;
  const w = (m01 - m10) * num2;
  return new Quaternion(x, y, z, w);
};

$.onUpdate(deltaTime => {
  // 略
  if (length > 0) {
    const newRot = rot.clone().slerp(lookRotation(newVelocity, vector3Up), 0.8 * deltaTime);
    $.setRotation(newRot);
  }
  // 略
});

スクリプト全文

最終的にはこのようなスクリプトになりました。

const vector3Zero = new Vector3(0, 0, 0);
const vector3Up = new Vector3(0, 1, 0);
const mathRad2Deg = 180 / Math.PI;

const gravity = new Vector3(0, -9.8, 0);
const airDensity = 1.293;

const wingArea = 0.4 * 0.3;

// https://answers.unity.com/questions/467614/what-is-the-source-code-of-quaternionlookrotation.html
const lookRotation = (forward, up) => {
  forward = forward.clone().normalize();
  const right = up.clone().cross(forward).normalize();
  up = forward.clone().cross(right);

  const m00 = right.x;
  const m01 = right.y;
  const m02 = right.z;
  const m10 = up.x;
  const m11 = up.y;
  const m12 = up.z;
  const m20 = forward.x;
  const m21 = forward.y;
  const m22 = forward.z;

  const num8 = (m00 + m11) + m22;
  if (num8 > 0)
  {
    var num = Math.sqrt(num8 + 1);
    const w = num * 0.5;
    num = 0.5 / num;
    const x = (m12 - m21) * num;
    const y = (m20 - m02) * num;
    const z = (m01 - m10) * num;
    return new Quaternion(x, y, z, w);
  }
  if ((m00 >= m11) && (m00 >= m22))
  {
    var num7 = Math.sqrt(((1 + m00) - m11) - m22);
    var num4 = 0.5 / num7;
    const x = 0.5 * num7;
    const y = (m01 + m10) * num4;
    const z = (m02 + m20) * num4;
    const w = (m12 - m21) * num4;
    return new Quaternion(x, y, z, w);
  }
  if (m11 > m22)
  {
    var num6 = Math.sqrt(((1 + m11) - m00) - m22);
    var num3 = 0.5 / num6;
    const x = (m10 + m01) * num3;
    const y = 0.5 * num6;
    const z = (m21 + m12) * num3;
    const w = (m20 - m02) * num3;
    return new Quaternion(x, y, z, w);
  }
  var num5 = Math.sqrt(((1 + m22) - m00) - m11);
  var num2 = 0.5 / num5;
  const x = (m20 + m02) * num2;
  const y = (m21 + m12) * num2;
  const z = 0.5 * num5;
  const w = (m01 - m10) * num2;
  return new Quaternion(x, y, z, w);
};

const calcLiftOfDrag = (coefficient, surface, velocity) => {
  return 0.5 * airDensity * (velocity * velocity) * surface * coefficient;
}

$.onGrab(isGrab => {
  if (!isGrab) {
    $.state.velocity = new Vector3(0, 0, 10).applyQuaternion($.getRotation());
  }
  $.state.isGrab = isGrab;
});

$.onUpdate(deltaTime => {
  const pos = $.getPosition();
  const rot = $.getRotation();
  const isGrab = $.state.isGrab;

  if (isGrab) {
    return;
  }

  const velocity = $.state.velocity ?? vector3Zero;
  const length = velocity.length();

  const localVelocity = velocity.clone().applyQuaternion(rot.clone().invert());
  const aoa = -Math.atan2(localVelocity.y, localVelocity.z) * mathRad2Deg;
  
  const liftFactor = calcLiftOfDrag(aoa, wingArea, length);
  const liftVector = velocity.clone()
    .cross(vector3Up.clone().applyQuaternion(rot))
    .cross(velocity)
    .normalize();

  // https://sites.google.com/view/ronsu900/createfs/wing1#h.79gl52y8ytmb
  const cd = Math.min(Math.max(0.005 + Math.pow(Math.abs(aoa) * 0.0315, 5), 0), 1);
  const drag = calcLiftOfDrag(cd, wingArea, length);
  const dragVector = velocity.clone().normalize().multiplyScalar(-1);

  const newVelocity = velocity.clone()
    .add(liftVector.clone().multiplyScalar(liftFactor * deltaTime))
    .add(dragVector.clone().multiplyScalar(drag * deltaTime))
    .add(gravity.clone().multiplyScalar(deltaTime));
  $.state.velocity = newVelocity;
  const newPos = pos.clone().add(newVelocity.clone().multiplyScalar(deltaTime));
  const boundedNewPos = new Vector3(newPos.x, Math.max(newPos.y, 0), newPos.z);
  $.setPosition(boundedNewPos);

  if (length > 0) {
    const newRot = rot.clone().slerp(lookRotation(newVelocity, vector3Up), 0.8 * deltaTime);
    $.setRotation(newRot);
  }

  if (newPos.y < 0) {
    $.state.velocity = vector3Zero;
  }
});

おわりに

このように Logic で書いていたものの再実装を試みることで、改めてスクリプトの強力さを実感しますね! (ところで昨年の記事中の疑似コードは伏線だったのか、果たして……)

明日は warabi_mochi さんの ScriptableItemでTypeScriptを活用してみた です。

参考

Discussion