TypeScriptでコンストラクタを書くのが面倒すぎる問題と一案

この記事は、TypeScript で Python の dataclasses や Go のメソッド付き構造体みたいなことがやりたいという動機で書かれた。

問題提起

構造体、レコード、あるいはデータクラスなどと呼ばれるものを TypeScript で表現するとき、まず選択肢になるのはオブジェクトリテラルだ。

type Player = {
  id: string;
  hp: number;
  maxHp: number;
};

const player: Player = { id: "hoge", hp: 15, maxHp: 20 };

だがメソッドを使いたいならこの書き方は適さない。

例えば、コード中に player.maxHp - player.hp の計算が頻出することに気づいたとする。この計算を player.damage() とメソッド呼び出しで書ければ書き心地が良さそうだ。

そのためにはクラスを宣言することになる。しかし……

class Player {
  id: string;
  hp: number;
  maxHp: number;

  constructor (props: {
    id: string;
    hp: number;
    maxHp: number;
  }) {
    this.id = props.id;
    this.hp = props.hp;
    this.maxHp = props.maxHp;
  }

  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player.damage()); // 5

クラスにすると随分と記述量が増えてしまった。

(コンストラクタ引数は positional arguments ではなくオブジェクトにまとめて受け取る。なぜなら hpmaxHp の順番を気にしたくないからだ)

クラス定義中の各インスタンスフィールド名 (hpmaxHp) の出現回数に注目してほしい。

  • (1) フィールドの型注釈
  • (2) コンストラクタ引数の型注釈
  • (3) 代入の左辺
  • (4) 代入の右辺

なんと1つのフィールドにつき最低4か所に同じ識別子を書いている。これは冗長すぎないか?

要するに、すべてのフィールドをコンストラクタ引数で初期化するクラスを TypeScript で作るとコンストラクタを書くのが面倒なのである。

これをいかに楽にするかが本記事のテーマだ。

提案

抽象クラス(のコンストラクタ)Struct を次のように定義する。

const Struct = class {
  constructor(props: object) {
    Object.assign(this, props);
  }
} as abstract new <P extends Record<string, unknown>> (props: P) => P;

Structはコンストラクタ引数をインスタンスにコピーするだけのクラスだ。したがって、これを継承すれば派生クラスにはコンストラクタを書かなくて良い。

上記のPlayerクラスもこの通り↓

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp: number;
}> {
  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player.damage()); // 5

綺麗に書けた。もちろん型によるコード補完も効く。勝ったな。

型安全?

壊そうと思えば……

class Foo {
  f() {}
}

class Bar extends Struct<{
  f: () => void
}> {}

const foo = new Foo();
const bar = new Bar(foo);
bar.f(); // 実行時型エラー! bar.f is not a function 

このように Object.assign でコピーできないプロパティが混入すると壊れる。

とはいえ、コンストラクタ引数に与えるのが「オブジェクトリテラル」か「同じクラスのインスタンス」である限り大丈夫なので、実用上は困らないと思う。

型の解説

定義を再掲:

const Struct = class {
  constructor(props: object) {
    Object.assign(this, props);
  }
} as abstract new <P extends Record<string, unknown>> (props: P) => P;
  • as(型アサーション)で無理やり型を上書きしている。
    • class 宣言 で定義したクラスの型を後から捻じ曲げる方法を私は知らないので、代わりにclass 式というあまり見ない構文を利用した。
  • 型変数 P は object literal type で具体化されることを想定している。
    • P が class や interface で具体化されるのは望ましくない。この制約は P extends Record<string, unknown> によって実現するが、如何なる原理でそうなるのか私は把握していない。

代替案

提案したStructを使わなくとも、データクラスの類を多少楽に書く方法はある。

案0: Parameter Properties

TypeScriptには parameter properties という構文がある。

class Player {
  constructor (
    public id: string,
    public hp: number,
    public maxHp: number,
  ) {}

  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player("hoge", 15, 20);  // positional arguments
console.log(player.damage()); // 5

シンプルに書けた。だがコンストラクタ引数が positional arguments になることに留意してほしい。

思うに、フィールドが多い場合や、同じ型のフィールドがある場合は苦しいのではないか。私は既に maxHphp の順番がわからない。

案1: コンストラクタを Object.assign で書く

type PlayerProps = {
  id: string,
  maxHp: number,
  hp: number
};
class Player implements PlayerProps {
  id!: string;
  maxHp!: number;
  hp!: number;

  constructor(props: PlayerProps) {
    Object.assign(this, props)
  }

  damage() {
    return this.maxHp - this.hp;
  }
}

const player = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player.damage()); // 5

普通に書くよりは楽か。implements を使うので typo の心配もない。しかしフィールドに ! (non-null assertion) を使う必要があるのが嬉しくない。

案2: メソッドを諦める

コンパニオンオブジェクトパターンという選択肢がある。

type Player = {
  id: string;
  maxHp: number;
  hp: number;
};

const Player = {
  damage(player: Player) {
    return player.maxHp - player.hp;
  }
}

const player: Player = { id: "hoge", maxHp: 20, hp: 15 };
console.log(Player.damage(player));

これで十分な場合も多いのではないか。メソッドが使えないから、メソッドチェーンしたいとか、オブジェクトが何らかのインターフェースを満たす必要があるとか、メソッドじゃないと書き心地が悪いとか、そういった用途には適さない。

デフォルト値が欲しい

コンストラクタ引数の一部を省略可能にしたい。さらに、省略されたフィールドは undefined ではなくデフォルト値で初期化されるようにしたい。可能だろうか?

先ほどの Player クラスで maxHp を省略したときにデフォルト値 100 で初期化するという例題を考える。

意外と厄介な問題だ。残念ながら次の書き方はできない↓

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp?: number;
}> {
  maxHp = this.maxHp ?? 100;  // any
}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

パブリックインスタンスフィールドの初期化子は基底クラスの初期化後に評価されるため JavaScript としては正しい。しかし TypeScript では型がつかない。

なお thissuper に変えると型チェックが通るが、super からはインスタンスフィールドを参照できないため動かない。型チェックが通ってしまうのがタチが悪いな。

案A: 諦めてコンストラクタを書く

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp: number;
}> {
  constructor(props: {
    id: string;
    hp: number;
    maxHp?: number;
  }) {
    super({ ...props, maxHp: props.maxHp ?? 100 });
  }
}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

お上品なコードだ。最低2か所に同じフィールド名を書く必要があるので気に入らないが、それでも Struct なしよりは楽かもしれない。

案B: 頑張る

コンストラクタを書くだと? 冗談じゃない。本気を出せばこの程度……

const Struct = class {
  constructor(props: object) {
    Object.assign(this, props);
  }
  get $() {
    return this;
  }
} as abstract new <P extends Record<string, unknown>> (props: P) => P & { get $(): P };

class Player extends Struct<{
  id: string;
  hp: number;
  maxHp?: number;
}> {
  maxHp = this.$.maxHp ?? 100;  // 外から見ても undefined を含まない number 型になる
}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

どうだ!?

まあいいのだが、$ なる謎の getter メソッドが生えることで可読性を損なっている。上品な書き方ではないな。

案C: デフォルト値付き構造体クラスを生成する

function StructWithDefaults<D extends object>(
  defaults: () => D
): new <P extends D>(props: Omit<P, keyof D> & Partial<D>) => P {
  return class StructWithDefaults {
    constructor(props: object) {
      Object.assign(this, props);
      const t = this as Record<string, unknown>;
      for (const [key, value] of Object.entries(defaults())) {
        t[key] ??= value;
      }
    }
  } as never;
}

class Player extends StructWithDefaults(() => ({
  maxHp: 100
}))<{
  id: string,
  hp: number,
  maxHp: number,
}> {}

const player1 = new Player({ id: "hoge", hp: 15 });
const player2 = new Player({ id: "hoge", hp: 15, maxHp: 20 });
console.log(player1.maxHp, player2.maxHp);  // 100, 20

なんだこれは。正気か?

ということで、省略可能なフィールドにデフォルト値を設定するのは不可能ではないが、流暢に書くのは少々難しいという結論になった。うまくいかないものだな

以上

抽象クラス Struct を定義してすべてのフィールドをコンストラクタ引数で初期化するクラスを楽に書く方法を提案した。もしよければ使ってみてくださいね。

参考記事

ここまで述べた内容は下記記事をリスペクトして発展させたものだ。

やっていることは同じだが、あちらは Structコンストラクタを返す generic な無引数関数 である(extends Struct<{}>()と書く)のに対して、こちらは Struct が案Cを除いて generic なコンストラクタである (extends Struct<{}>と書く)点が異なる。

Windowsで全角英数から戻らなくなったら「カタカナひらがなローマ字」キーを押す

Windowsでキーボードを叩いていると、突然IMEが全角英数モードになって戻らなくなることがある。こうなると半角/全角を切り替えても平仮名に戻らない。

この状態から戻すのに一番早いのは「カタカナひらがなローマ字」キーを押すことである。連打しても問題ない。この操作には冪等性がある。

ちなみに突然IMEが全角英数モードになる原因は「Shift + 無変換」のショートカットが暴発することである。

けん玉の技「灯台」を覚えた:可能だが自明ではない

無理だと思っていたが一週間くらい練習していたらたまに決まるようになった。ただ、いくらかの情報収集と気づきが必要だったので、メモを残しておく。

 

私の場合は灯台を成功させるまでに以下の問題を解決する必要があった。

  • 問題1: 手で乗せることすらできない
  • 問題2: ビーズが玉から飛び出して邪魔
  • 問題3: つり下げたけんが静止しない
  • 問題4: 接触の衝撃でバランスが崩れる

 

灯台とは

玉を持ち、つり下げたけんを引き上げて、玉の上に中皿を乗せて止める技

←見る人が見たらキレそう

玉を持ち、けんを下につり下げて構える。玉を動かしてけんを回転させずに、鉛直上方に引き上げ、玉の上に中皿を乗せてけんを立て静止させる。けん及び体の動きを少なくとも3秒静止させること。

出典: ググったら出てきた日本けん玉協会の資料

 

問題1: 手で乗せることすらできない

*1によるとけん玉の滑りやすさには個体差があるらしい。が、お使いのけん玉が日本けん玉協会の認定品である限り、乗りはすると思う。少しだけ練習は要る。

乗せるときに重心がずれると乗らない。けんの先っちょをつまむと重力に従って良い感じになるので、そのまま玉の上に置くと自然に乗る。それを3秒間キープできれば良い。

けんが滑りだしたときに抗えず落ちてしまうのは、そういうものなので、それで自分のけん玉は不良品なのかと心配する必要はない。崩れたバランスを立て直す方法は別に存在する(後述)

 

問題2: ビーズが玉から飛び出して邪魔

玉の糸穴近くに結び目を作れば解決する。

結び目はズルではなくて日本けん玉協会が推奨している方法なので安心→ はじめてのけん玉 – 公益社団法人日本けん玉協会

ちなみに糸を結ぶときにけん玉を分解する必要はない。

 

問題3: つり下げたけんが静止しない

つり下げた状態のけんを止めたまま保つのは難しいが、一瞬だけ動きを止めるのは難しくない。その間に引き上げてしまえばスムーズに技を開始できる。

これは動画を見るとわかりやすい。

技の開始時に注目すると、次のような動きをしている:

  1. 玉を高めの位置で持つ
  2. 反対の手をけんに添えて動きを止める
  3. すぐに体を沈み込ませ、ためらわず引き上げる(重要)

これを真似したらうまくいった。糸が多少捻じれていても回りだす前に引き上げれば何とかなるということだな。

でも捻じれすぎるとダメで、そういうときは普段と反対の手でとめけんの構えを作るとねじれが取れる。

 

ところで、けんを引き上げる動作と乗せてからバランスを取る動作は別物なので、バランスを取れなくても引き上げを練習すると単体で上達が見込める。具体的には一瞬乗ったあと滑り落ちて「できる人はここから止められるんだろうな」という感触が得られるようになり、わりとモチベーション維持に良かった。

 

問題4: 接触の衝撃でバランスが崩れる

一般論として膝を使って衝撃を和らげろという話があるが、灯台に関してはそれだけでは足りない。むしろ重要なのは崩れたバランスを立て直す動作だと思う。

上手な灯台は一瞬で安定するので見てもわかりにくい。さっきの動画では、最後に出てくる灯台の派生技でバランスを取る動作を見ることができる。下手な灯台は毎回このように足掻くことになる。

私はこの動作が全然できなくて、本当に可能なのかと疑心暗鬼に陥りかけたが、ある日バランスを取るときにけん先を見るようにしたら少し延命できるようになった。その日のうちに灯台は成功した。

とはいえ成功率は低いので、私は灯台を十分に理解していないのだろう。皿の縁を見ろと言う人もいる*2。なんか色々変えて試してみると良いのではないか。

わざとバランスを崩して立て直す練習をするのはおそらく有効。玉の上にけんを置いて止めたあと、玉を持つ手を適当にふるわせると実際の灯台に近い感触になると思う。

追記

1~2ヵ月ほど何も考えずに練習を続けたらほぼ失敗しなくなった。もはや椅子に座ったままでも成功する。

本当に何も考えずにやっていたので、バランスを取るときどこを見れば良いのか今はもうわからない。体で覚えるしかないのかもしれない。

以上

どうでもいいが、けん玉が健康に良いという話には私は懐疑的だ。なんか首とか腰とかが痛くなるし、目を見開くので目は乾燥するし、妙に依存性があるので家事を中断して手を出してしまうことがある。総じてあまり良くない。

XのPostのブックマーク数を一覧する合法UserScript (2024年12月)

ツイートのブックマーク数はツイート詳細画面に行かないと見えない。

プロフィール画面とかでブックマーク数を一覧したいと思った。JavaScriptを書いた。見えるようになった。

ブックマーク数を一覧できる

Xの内部APIを勝手に叩くのではなく、Webクライアントと内部APIのやり取りを覗き見するだけなので規約違反ではないと思う。

規約→ Xの自動化開発ルール | Xヘルプ

ただし全てのXHRに介入してDOM変更も監視するのでブラウザのパフォーマンスに悪影響を与える可能性はある。

せっかく作ったのでコードを共有してみる。クオリティは全然保証しない。たとえばページによっては動かないし、ツイートidとブックマーク数の対応をリセットするタイミングはなんか間違っている。

gist.github.com

このスクリプトのキモはXHRを乗っ取る部分だ。最初はこんなんじゃなくてChrome拡張のWeb Request APIを使おうとしたんだが、レスポンスボディにはアクセスできないので頓挫した。

  class C extends window.XMLHttpRequest {
    constructor (...args) {
      super(...args);
      this.addEventListener('load', () => {
        if (this.status === 200) {
          analyze(this.responseURL, () => JSON.parse(this.responseText));
        }
      })
    }
  }
  window.XMLHttpRequest = C;

でも噂によると、ReactのPropsから情報を引っこ抜けば内部APIのレスポンスを処理しなくてもツイートの情報を取れるらしい。私はもう知らん

噂↓

Switchの画像を「スマホに送る」でPCに送る:CLIでWi-Fi切替と画像DLを半自動化 (Windows11)

Nintendo Switchの X (Twitter) 連携は2024年6月に終了した。

その代替手段としてSwitchのアルバムには「スマートフォンに送る」機能がある。しかしその実態は「Switchがアクセスポイントになって画像をHTTPでサーブする」という力技で、まあ相当にめんどくさい。

↑一枚目のQRコードwifi:から始まる文字列が入っている)に含まれるSSIDとパスワードでアクセスポイントを切り替え、二枚目のQRコードhttp://192.168.0.1/index.htmlを開くという仕組みだ。

いやこれ、本当にあらゆる点が面倒で、例えばモバイルデータ通信が有効になっていると192.168.0.1へのアクセスにWi-Fiが使われず永遠にページが開けなかったりする。

スマートフォンに送る」でPCに送る

楽をするために、とりあえずスマホではなくPCでSwitchに繋ぐことから始めたい。PCからではQRコードが読みにくいが、実はQRコードを読まなくても、この画面で+ボタンを押すことでパスワードがわかる。

このパスワードを手入力すればPCから繋げる。しかし、まだ全然楽にはなっていない。

やはり面倒くさい

一度アクセスポイントに接続するとプロファイルが残る。普通ならこれで2回目から楽に繋げるようになるわけだが、Switchの場合これが裏目に出る。

Switchが提供するSSIDはおそらく固定。一方パスワードは毎回変わる。そしてWindows11のGUIから一度繋いだ接続先のパスワードを直接変更することはたぶんできない。一度プロファイルを削除してから、再度繋ぎなおす必要がある。

これはつまり、2回目以降にPCからSwitchに繋ぐのは1回目に繋ぐのより面倒だということだ。マジか。

というか別に面倒なのは接続だけではなく、ブラウザを開いて画像を一枚ずつDLするのはスマホではなくPCでも十分面倒だし、終わった後にアクセスポイントを元に戻す(さもなくばインターネットが使えない)のも面倒である。

流石に心が折れそうだ。かくなる上はスクリプトを書いて一連の作業を自動化するしかない。

Pythonで半自動化する

「パスワードを入力すると、自動でアクセスポイントが切り替わり、画像を勝手にダウンロードして、終わったら元のアクセスポイントに切り替わる」ようなスクリプトを書く。

WindowsCLIからアクセスポイントを切り替えるにはnetshコマンドが利用できる。

コマンドプロンプトは使い方がよくわからないのでWSL2でnetsh.exeを呼び出して使うことにした。あとシェルスクリプトもよくわからないのでPythonを書いた:

#!/usr/bin/env python3

import subprocess
import re
import os

import requests

# 画像ファイルの保存先
savepath = "/mnt/c/Users/roodni/Pictures/"

print("保存先:", savepath)
assert os.path.isdir(savepath)

def run_netsh_wlan(command: list[str]):
  args = ["netsh.exe", "wlan"] + command
  print(">", " ".join(args))
  res = subprocess.run(args, capture_output=True, encoding="shift-jis")
  print(res.stdout)
  assert res.returncode == 0
  return res

# SwitchらしきアクセスポイントのWi-Fiプロファイルを探す
show_profiles = run_netsh_wlan(["show", "profiles"])
ssids_for_switch: list[str] = re.findall(r": (switch_[0-9A-Za-z_\-]+)\n", show_profiles.stdout)

# プロファイルが見つからない場合はエラーにする
# 2つ以上ある場合も怖いのでエラーにする
assert len(ssids_for_switch) == 1
profile = ssids_for_switch[0]
print("SSID:", profile)

# パスワードを変更する
key = input("パスワードを入力: ")
assert re.fullmatch(r"[0-9A-Za-z_\-]+", key)
run_netsh_wlan(["set", "profileparameter", f"name={profile}", f"keyMaterial={key}"])

# Switchに接続する
run_netsh_wlan(["connect", f"name={profile}"])

def get(filename: str) -> requests.Response:
  url = "http://192.168.0.1/" + filename
  print("GET", url)
  res = requests.get(url, timeout=(10, None)) # パスワードを間違えていた場合はタイムアウトになる
  print(res)
  assert res.ok
  return res

# ファイル一覧を取得する
data_json = get("data.json")

# 画像をすべてダウンロードする
for filename in data_json.json()["FileNames"]:
  assert ".." not in filename, filename
  res = get("img/" + filename)
  with open(os.path.join(savepath, filename), "wb") as f:
    f.write(res.content)

# Switchから切断して、元のアクセスポイントに戻りやすくする
run_netsh_wlan(["disconnect"])

このスクリプトを使う前に、一度だけGUIからSwitchのアクセスポイントに繋いでWi-Fiプロファイルを残しておく必要がある。この操作は2回目からは必要ない。スクリプトはプロファイル一覧からSwitchっぽいやつを探し出し、パスワードだけを毎回変更して接続する。

使用感はこんな感じ↓

→ ./switch.py

保存先: /mnt/c/Users/roodni/Pictures/
> netsh.exe wlan show profiles

インターフェイス Wi-Fi のプロファイル:

グループ ポリシー プロファイル (読み取り専用)
---------------------------------------------
    <なし>

ユーザー プロファイル
---------------------
    すべてのユーザー プロファイル     : switch_**********g
    すべてのユーザー プロファイル     : **********
    すべてのユーザー プロファイル     : **********


SSID: switch_**********g
パスワードを入力: ********
> netsh.exe wlan set profileparameter name=switch_**********g keyMaterial=********

インターフェイス "Wi-Fi" 上のプロファイル "switch_**********g" が正常に更新されました。


> netsh.exe wlan connect name=switch_**********g
接続要求が正常に完了しました。

GET http://192.168.0.1/data.json
<Response [200]>
GET http://192.168.0.1/img/2024011317524700-B6CE40797459B0890BF7CEF68A4CE587.jpg
<Response [200]>
GET http://192.168.0.1/img/2023072721404400-B6CE40797459B0890BF7CEF68A4CE587.jpg
<Response [200]>
GET http://192.168.0.1/img/2023072721392500-B6CE40797459B0890BF7CEF68A4CE587.jpg
<Response [200]>
> netsh.exe wlan disconnect
インターフェイス "Wi-Fi" の切断要求が正常に完了しました。

これで私は満足した。

なおこのスクリプトは私の環境では動作するが、万人の環境で動くものではない。例えばSwitchを2台持っている人のことは考慮していない。

ちなみに、PCならUSBケーブルで転送するのが一番早いだろと言われると私には返す言葉がない。

ニコニコ動画のコメントを正規表現でNGするユーザースクリプト(Re: 仮)

ニコニコ動画が復活したと聞いて見に行ってみれば……荒らされているではないか! NG機能のないニコ動がこれほどの無法地帯になろうとは!

昔作ったユーザースクリプトが動いた

以下のコードをTampermonkeyに突っ込めばNG機能の代替になる。

ESCで設定画面が開くので|で区切ってNGワードを好きなだけ登録してほしい。設定はローカルストレージに保存されて永続する。 詳しくは 過去記事 ニコニコ動画のコメントを正規表現でNGするユーザースクリプト - AdC用にとりあえず用意したブログ に書いてある。

gist.github.com

剛体物理シミュレーションを書いた(衝突応答編)

https://roodni.github.io/rigidbody/ で遊べます

想像以上に安定して動作したので、実装方法について記しておきます。

作るもの

  • 2Dの剛体シミュレーション
  • 衝突・静止摩擦・動摩擦

剛体が持つ情報

  • 質量  m
  • 重心回りの慣性モーメント  I
  • 重心の位置  \vec{x}
  • 重心の速度  \vec{v}
  • 重心の加速度  \vec{a}
  • 角度  \theta
  • 角速度  \omega
  • 角加速度  \alpha

ステップ(時間を進める)

  1. 重力加速度の加算
  2. 数値積分
  3. 衝突判定
  4. 衝突応答
  5. 加速度・角加速度のリセット

1. 重力加速度の加算

剛体の加速度(毎回リセットされて \vec{0} になる)に重力加速度を加えてください。 重力以外の力もあれば同様に加速度や角加速度に加えるのが良いと思います。

速度に直接加算するのは、本記事の枠組みの中ではよろしくないです。

  • 速度に直接加算すると加えた力が剛体の位置に瞬時に反映されます。すると衝突応答(剛体の速度を変更する)では吸収できない振動が生じてしまいます。
  • 加速度は衝突応答の安定化のために利用できます。

2. 数値積分

オイラー法を使いました。一番単純なやつです。

進める時間を\Delta tとして:

 \begin{align}
\vec{x}_{i+1} &= \vec{x}_i + \vec{v}\Delta t \\
\vec{v}_{i+1} &= \vec{v}_i + \vec{a}\Delta t \\
\theta_{i+1} &= \theta_i + \omega\Delta t \\
\omega_{i+1} &= \omega_i + \alpha\Delta t
\end{align}

3. 衝突判定

私の実装では今のところ衝突判定で剛体の速度を考慮していません。位置が重なっているかどうかだけを見ます。 速すぎると貫通しますが……大抵は大丈夫です。ちゃんとした物理エンジンは貫通対策をしているらしいです、すごいですね。

本記事では衝突判定の詳細には触れません。 どうにかして衝突点・法線ベクトル・めり込みの深さを取得してください。 面衝突の場合は衝突点が2個あると安定します。

ちなみに凸多角形同士の衝突判定には次の2つの方法が有力な選択肢です:

  • 分離軸判定
  • GJK-EPA

4. 衝突応答

本記事での衝突応答とは「衝突を剛体の速度に関する拘束と見なして、拘束を満たすように撃力を加えて、剛体の速度を瞬間的に変更する」ことです。

具体例を出しましょう。剛体1と剛体2が反発係数  e で衝突して、衝突前の法線方向の相対速度が  v_{12} だったとします。 このとき、衝突後の法線方向の相対速度は -ev_{12} であってほしいです。それを満たすように撃力を加えます。

もし静止摩擦があれば、衝突後の接線方向の相対速度がゼロになるという拘束も加わります。ただし摩擦力の上限が垂直抗力の大きさに比例することに注意が必要です。他にもいろいろ考慮して拘束条件を組み立てます。

撃力を求めるには複数の衝突をまとめてLCP(線形相補性問題)に定式化して解くのが一般的らしいですが、 本記事では各衝突をそれぞれ単体で解いて撃力を与えるのを繰り返す方法を扱います。

剛体の衝突と撃力

剛体1と剛体2が衝突するときに、剛体1から剛体2に与えられる撃力  \vec{F} を求めます。他の衝突はここでは無視します。

  • 衝突位置  \vec{p}
  • 剛体1から剛体2への衝突法線 (単位ベクトル) \vec{n}
  • 接線(単位ベクトル)\vec{t} = R(\frac{\pi}{2}) \vec{n}Rは回転行列)
  • 反発係数  e
  • 摩擦係数  \mu(簡単のため静止摩擦係数 = 動摩擦係数とする)

その他文字一覧:

\begin{align}
\vec{r}_1 &= \vec{p} - \vec{x}_1 \\
\vec{r}_2 &= \vec{p} - \vec{x}_2 \\
M^{-1}_{n} &= \frac{1}{m_1} + \frac{1}{m_2} + \frac{(\vec{r}_1\times\vec{n})^2}{I_1} + \frac{(\vec{r}_2\times\vec{n})^2}{I_2} \\
M^{-1}_{t} &= \frac{1}{m_1} + \frac{1}{m_2} + \frac{(\vec{r}_1\times\vec{t})^2}{I_1} + \frac{(\vec{r}_2\times\vec{t})^2}{I_2} \\
M^{-1}_{nt} &= \frac{(\vec{r}_1\times\vec{n})(\vec{r_1}\times\vec{t})}{I_1} + \frac{(\vec{r}_2\times\vec{n})(\vec{r_2}\times\vec{t})}{I_2}
\end{align}

\times外積です。2次元での外積スカラーになることに留意してください)

導出は省きますが、位置 \vec{p}での相対速度\vec{\nu}_{12}と、撃力\vec{F}=\lambda_n\vec{n} + \lambda_t\vec{t}を相互に作用させたあとの相対速度\vec{\nu}'_{12}はそれぞれ次のように表せます。

\begin{align}
\vec{\nu}_{12} &= \vec{v}_2 + \omega_2 R\left(\frac{\pi}{2}\right) \vec{r}_2 - \vec{v}_1 - \omega_1 R\left(\frac{\pi}{2}\right) \vec{r}_1 \\
\vec{n}\cdot\vec{\nu}'_{12} &= \vec{n}\cdot\vec{\nu}_{12} + \lambda_n M^{-1}_n + \lambda_t M^{-1}_{nt} \\
\vec{t}\cdot\vec{\nu}'_{12} &= \vec{t}\cdot\vec{\nu}_{12} + \lambda_n M^{-1}_{nt} + \lambda_t M^{-1}_{t}
\end{align}

まずは静止摩擦が働く場合の撃力を求めてみましょう。 法線方向の目標相対速度を u(基本は u=-e\vec{n}\cdot\vec{\nu}_{12} ですが補正が入ります)として、 連立方程式

\left\{\begin{aligned}
\vec{n}\cdot\vec{\nu}'_{12} &= u \\
\vec{t}\cdot\vec{\nu}'_{12} &= 0
\end{aligned}\right.

\lambda_n\lambda_tについて解きます。解は省略しますがゼロ除算は発生しないので安心してください。

さて、解が |\lambda_t| \leq \mu\lambda_nを満たしていればそれで良いのですが、そうでなければ動摩擦に切り替えます。 先ほど求めた\lambda_tの符号を sとして、\lambda_t = s\mu\lambda_n\vec{n}\cdot\vec{\nu}'_{12} = uに代入して解けば、次の解が得られます:

\displaystyle
\lambda_n = \frac{u-\vec{n}\cdot\vec{\nu}_{12}}{M^{-1}_{n} + s\mu M^{-1}_{nt}}

先ほどの連立方程式の解について |\lambda_t| > \mu\lambda_nならば M^{-1}_{n} + s\mu M^{-1}_{nt} > 0が成り立つので安心してください。

以上で撃力\vec{F}=\lambda_n\vec{n} + \lambda_t\vec{t}が求まりました。 ただし法線方向の目標相対速度 uの設定についてもう少し考える必要があります。

法線方向の目標相対速度の設定

ベースは u=-e\vec{n}\cdot\vec{\nu}_{12} ですが、安定化のために補正を入れます。

重力加速度による補正

重力加速度 gで落下する質点が水平面に速さ vで衝突して、反発係数 eで跳ね返ったとします。 計算は省きますが、本記事で説明した数値積分の方法では、再衝突の速さは最大で ev + g\Delta tになります。

再衝突の速さが本来より g\Delta tだけ大きくなる可能性があるわけです。 このままでは、とくに反発係数が大きいときに、相対速度がゼロに収束せず跳ね続けるという事態が発生します。

そこで目標相対速度から g\Delta tに相当する量を引くとうまくいきました。 具体的には uの値を次のとおりに変更します:

\begin{align} u_{g} &= \min(0,\ \vec{n}\cdot \vec{g}\Delta t) \\ u &= \max(0,\ -e\vec{n}\cdot \vec{\nu}_{12} + u_g) \end{align}

 \vec{g}のかわりに剛体の相対加速度を使うのもアリだと思います。

ついでに、衝突前の相対速度が十分小さいとき、この補正によって目標相対速度がゼロになります。 これで床に置かれた剛体が重力による加速で振動することも防げます。

めり込み解消のための補正

めり込みの深さを \mathit{depth}、許容めり込み量を \mathit{slop}として \frac{\mathit{depth}-\mathit{slop}}{\Delta t}

ただし\mathit{depth}が大きいと反発係数以上に反発することになるので、適切に上限を設定した方が良いかもしれません。

反復

各衝突をそれぞれ単体で解くのを繰り返して撃力を伝播させます。

最初に各衝突について法線方向の目標相対速度を計算しておきます。 これをすべて達成することを目指して撃力を計算します。反復の途中で目標相対速度を更新する必要はたぶんないです。

以下を繰り返す:

  • 各衝突について以下の操作を行う:
    1. 前回の反復で与えた撃力を反転して速度に加算する(打ち消す)
    2. 法線方向の相対速度が目標相対速度より大きければ何もしない。撃力はゼロ
    3. そうでなければ、上で述べた計算式で撃力を計算する
    4. 撃力を剛体の速度に加算する

反復を開始する前に前回のタイムステップで求めた撃力を加えること (Warm start) で撃力計算が安定します。 時刻をまたいだ衝突点の同定がやや面倒ですが、本当に目に見えて安定するのでおすすめです。

おわり

ここまで実装すれば動きます。

本記事では私が実装で迷った部分にできるだけ言及したつもりです。 実のところ、数値積分の方法からして正しいかどうか自信がないのですが、それっぽく動いたので大丈夫ということにして記事を書きました。 物理エンジン自作に興味があるがよくわからない、という方の参考になれば幸いです。