BabylonJS カメラ入力をカスタマイズする

# babylonjs のカメラ移動をカスタマイズする

babylon.js では[`UniversalCamera`][uc]というカメラが用意されています。[`UniversalCamera`][uc]はファーストパーソンシューティングのようなカメラ操作を提供してくれます。[`FreeCamera`](https://doc.babylonjs.com/typedoc/classes/BABYLON.FreeCamera)のマウス、キーボード操作にタッチとゲームパッド操作を追加したものです。

[`UniversalCamera`][uc]にはデフォルトでキーボードによる平行移動が実装されていますが、カメラの向いた方を正面としてその姿勢のまま移動方向を決定してしまうため上を見上げていると空に向かって移動しようとしてしまいます。今回はこのデフォルトの移動の代わりに水平方向にしか移動しないキーボード移動を実装してみます。[Customizing Camera Inputs](https://doc.babylonjs.com/features/featuresDeepDive/cameras/customizingCameraInputs/) に従って4つの関数を実装したオブジェクトを[`UniversalCamera`][uc]に渡します。

フィールド作成には[`Node Geometry`](https://doc.babylonjs.com/features/featuresDeepDive/mesh/nodeGeometry)を使用しました。`Align`ブロックを用いて床面の法線にあわせて箱の向きを変えています。材質はインデックスを指定して後でビルド後のメッシュに[`MultiMaterial`](https://doc.babylonjs.com/typedoc/classes/BABYLON.MultiMaterial)を指定することで複数材質が指定できます。

[`UniversalCamera`][uc]にはメッシュとの衝突判定が用意されていますのでこれも有効にします。これでフィールド上を歩き回るような操作が実現できます。詳しくは [`Camera Collisions`](https://doc.babylonjs.com/features/featuresDeepDive/cameras/camera_collisions/) を参照してください。

以下のコードは 2024年12月15日の [Babylon.js Playground](https://playground.babylonjs.com/) 7.40.2(WebGL2) Javascript で動作したことがあります。

```javascript
const _assumedFramesPerSecond = 60;

/**
 * 水平方向に平行移動するカメラキー移動
 */
class HorizontalCameraKeyboardMoveInput {
  /**
   * 上位からセットされる
   * @type {BABYLON.Camera}
   */
  camera = null;
  engine = null;
  scene = null;
  moveSpeed = 0.4 / _assumedFramesPerSecond;
  shiftRate = 2.0;
  ctrlRate = 0.5;
  preShift = false;
  preCtrl = false;
  codes = [];
  codesUp = ['KeyW'];
  codesDown = ['KeyS'];
  codesLeft = ['KeyA'];
  codesRight = ['KeyD'];
  onCanvasBlurObserver = null;
  onKeyboardObserver = null;

  attachControl(noPreventDefault) {
    noPreventDefault = BABYLON.Tools.BackCompatCameraNoPreventDefault(arguments);
    if (this.onCanvasBlurObserver) {
      return;
    }

    this.scene = this.camera.getScene();
    this.engine = this.scene.getEngine();

    this.onCanvasBlurObserver = this.engine.onCanvasBlurObservable.add(() => {
      this.codes.length = 0;
    });

    this.onKeyboardObserver = this.scene.onKeyboardObservable.add((info) => {
      const evt = info.event;
      const {code, shiftKey, ctrlKey} = evt;
      this.preShift = shiftKey;
      this.preCtrl = ctrlKey;
      if (evt.metaKey) {
        return;
      }

      if (info.type === BABYLON.KeyboardEventTypes.KEYDOWN) {
        if (this.codesUp.indexOf(code) >= 0 ||
          this.codesDown.indexOf(code) >= 0 ||
          this.codesLeft.indexOf(code) >= 0 ||
          this.codesRight.indexOf(code) >= 0) {
          const index = this.codes.findIndex(v => v.code === code);
          if (index < 0) { // 存在しなかったら追加する
            this.codes.push({code});
          }
          if (!noPreventDefault) {
            evt.preventDefault();
          }
        }
      } else {
        if (this.codesUp.indexOf(code) >= 0 ||
          this.codesDown.indexOf(code) >= 0 ||
          this.codesLeft.indexOf(code) >= 0 ||
          this.codesRight.indexOf(code) >= 0) {
          const index = this.codes.findIndex(v => v.code === code);
          if (index >= 0) { // 存在したら削除する
            this.codes.splice(index, 1);
          }
          if (!noPreventDefault) {
            evt.preventDefault();
          }
        }
      }

    });
  }

  detachControl() {
    this.codes.length = 0;
    if (!this.scene) {
      return;
    }

    if (this.onKeyboardObserver) {
      this.scene.onKeyboardObservable.remove(this.onKeyboardObserver);
    }
    if (this.onCanvasBlurObserver) {
      this.engine.onCanvasBlurObservable.remove(this.onCanvasBlurObserver);
    }
    this.onKeyboardObserver = null;
    this.onCanvasBlurObserver = null;
  }

  checkInputs() {
    if (!this.onKeyboardObserver) {
      return;
    }
    const camera = this.camera;
    for (let index = 0; index < this.codes.length; index++) {
      const {code} = this.codes[index];

      const local = new BABYLON.Vector3();
      if (this.codesLeft.indexOf(code) >= 0) {
        local.x += -1;
      } else if (this.codesUp.indexOf(code) >= 0) {
        local.z += 1;
      } else if (this.codesRight.indexOf(code) >= 0) {
        local.x += 1;
      } else if (this.codesDown.indexOf(code) >= 0) {
        local.z += -1;
      }
      if (camera.getScene().useRightHandedSystem) {
        local.z *= -1;
      }

      if (local.length() === 0) {
        continue;
      }

      const dir = camera.getDirection(local.normalize());
      dir.y = 0;
      dir.normalize();
      if (this.preShift) console.log('shift!');
      const rate = (this.preCtrl ? this.ctrlRate : (this.preShift ? this.shiftRate : 1));
      const move = dir.scale(this.moveSpeed * rate);
      camera.cameraDirection.addInPlace(move);
    }

  }

  getClassName() {
    return 'HorizontalCameraKeyboardMoveInput';
  }

  getSimpleName() {
    return 'horizontalkeyboard';
  }
}

const createScene = async () => {
  const earthGravity = -9.81;

  const scene = new BABYLON.Scene(engine);
  scene.collisionsEnabled = true;
  scene.gravity = new BABYLON.Vector3(0, earthGravity / _assumedFramesPerSecond, 0);

  const light = new BABYLON.HemisphericLight('light1',
    new BABYLON.Vector3(0.707, 0.707, 0),
    scene);

  const fieldSize = 51;
  const boxSize = 3;
  const boxNum = 57;
  {
    let count = 0;
    const _in = (val, type) => {
      const bl = new BABYLON.GeometryInputBlock(`bl${count}`, type);
      bl.value = val;
      count += 1;
      return bl;
    };
    const _float = (val, to) => {
      const bl = _in(val, BABYLON.NodeGeometryBlockConnectionPointTypes.Float);
      bl.output.connectTo(to);
    };
    const _int = (val, to) => {
      const bl = _in(val, BABYLON.NodeGeometryBlockConnectionPointTypes.Int);
      bl.output.connectTo(to);
    };
    const _v3 = (x, y, z, to) => {
      const bl = _in(new BABYLON.Vector3(x, y, z), 
        BABYLON.NodeGeometryBlockConnectionPointTypes.Vector3);
      bl.output.connectTo(to);
    };

    const ng = new BABYLON.NodeGeometry('node');
    {
      const bplane = new BABYLON.GridBlock('bplain');
      const bnoise = new BABYLON.NoiseBlock('bnoise');
      const bmul = new BABYLON.MathBlock('bmul');
      bmul.operation = BABYLON.MathBlockOperations.Multiply;
      const bpos = new BABYLON.GeometryInputBlock('bpos');
      bpos.contextualValue = BABYLON.NodeGeometryContextualSources.Positions;
      const badd = new BABYLON.MathBlock('badd');
      badd.operation = BABYLON.MathBlockOperations.Add;
      const bsetpos = new BABYLON.SetPositionsBlock('bsetpos');
      const bcompute = new BABYLON.ComputeNormalsBlock('bcompute');
      const bsetmtl = new BABYLON.SetMaterialIDBlock('bsetmtl');
      const bface = new BABYLON.InstantiateOnFacesBlock('bface');
      const bobj = new BABYLON.BoxBlock('bobj');
      const btrans = new BABYLON.GeometryTransformBlock('btrans');
      const bnormal = new BABYLON.GeometryInputBlock('bnormal');
      bnormal.contextualValue = BABYLON.NodeGeometryContextualSources.Normals;
      const balign = new BABYLON.AlignBlock('balign');
      const bmerge = new BABYLON.MergeGeometryBlock('bmerge');
      const out = new BABYLON.GeometryOutputBlock('out');

      _float(fieldSize, bplane.width);
      _float(fieldSize, bplane.height);
      _int(fieldSize, bplane.subdivisionsX);
      _int(fieldSize, bplane.subdivisionsY);

      _int(1, bsetmtl.id);
      bplane.geometry.connectTo(bsetmtl.geometry);

      bnoise.output.connectTo(bmul.left);
      _v3(0, 1, 0, bmul.right);
      bmul.output.connectTo(badd.left);
      bpos.output.connectTo(badd.right);

      bsetmtl.output.connectTo(bsetpos.geometry);
      badd.output.connectTo(bsetpos.positions);
      bsetpos.output.connectTo(bcompute.geometry);

      // 箱
      _float(boxSize, bobj.size);
      bobj.geometry.connectTo(btrans.value);
      _v3(0, boxSize * 0.5, 0, btrans.translation);

      bnormal.output.connectTo(balign.target);
      _v3(0, 1, 0, balign.source);
      // 面配置
      _int(boxNum, bface.count);
      btrans.output.connectTo(bface.instance);
      bcompute.output.connectTo(bface.geometry);
      balign.matrix.connectTo(bface.matrix);
      // 箱と面
      bface.output.connectTo(bmerge.geometry0);
      bcompute.output.connectTo(bmerge.geometry1);

      bmerge.output.connectTo(out.geometry);
      ng.outputBlock = out;
    }
    ng.build();
    const mesh = ng.createMesh('ngmesh', scene);
    mesh.checkCollisions = true;
    const mtls = new BABYLON.MultiMaterial('mtl', scene);
    const mtl0 = new BABYLON.StandardMaterial('mtl0');
    mtl0.diffuseTexture = new BABYLON.Texture('textures/crate.png');
    const mtl1 = new BABYLON.StandardMaterial('mtl1');
    mtl1.diffuseColor = new BABYLON.Color3(0.8, 0.8, 0.2);
    mtls.subMaterials.push(mtl0);
    mtls.subMaterials.push(mtl1);
    mesh.material = mtls;
  }

  const camera = new BABYLON.UniversalCamera('camera1',
    new BABYLON.Vector3(0, 25, 0),
    scene);
  camera.inputs.removeByType('FreeCameraKeyboardMoveInput');
  camera.inputs.add(new HorizontalCameraKeyboardMoveInput());
  camera.checkCollisions = true;
  camera.applyGravity = true;
  camera.needMoveForGravity = true;
  camera.minZ = 0.02;
  camera.attachControl(false);
  return scene;
};
```

|実行結果|
|:-:|
|![run1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213145/7007465b-20b3-4fe8-0901-d234d6d858b4.png)|
|WASDキーで平行移動、Shift同時で速く移動、Ctrl同時でゆっくり移動します|

|対応するノード|
|:-:|
|![node2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213145/46e93602-ea76-d65a-ba3d-f7a880eeaaea.png)|
|Node Geometry Editor で同じ構成のグラフを組んだ場合(ブロックの個別名は違います)|


# 参照

- カメラの紹介 UniversalCamera
https://doc.babylonjs.com/features/featuresDeepDive/cameras/camera_introduction#universal-camera
- キーボードイベントの code の値
https://developer.mozilla.org/ja/docs/Web/API/UI_Events/Keyboard_event_code_values


[uc]: https://doc.babylonjs.com/typedoc/classes/BABYLON.UniversalCamera