RapierのTypescrptで2Dの物理演算を試してみたメモ

## 概要
Rust製の物理エンジンで、WASMでJavaScriptでも動作可能ということだったので試してみた。

## 準備

ビルドにはvite を使った。 WASMの扱いには追加で設定が必要だった。[参考](https://vitejs.dev/guide/features.html#webassembly)

```diff:vite.config.ts
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
+ import wasm from "vite-plugin-wasm";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [preact()
+  , wasm()
  ],
})
```

<details><div>

```json:package.json
{
  "name": "vite-project",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@dimforge/rapier2d": "^0.11.2",
    "jotai": "^2.2.1",
    "preact": "^10.15.1",
    "rxjs": "^7.8.1"
  },
  "devDependencies": {
    "@preact/preset-vite": "^2.5.0",
    "typescript": "^5.1.6",
    "vite": "^4.3.9",
    "vite-plugin-wasm": "^3.2.2"
  }
}
```

```tsx:src/main.tsx
import { render } from 'preact'
import { App } from './app.tsx'

import './index.css'

render(<App />, document.getElementById('app') as HTMLElement)
```

```tsx:src/app.tsx
import './app.css'
import FalledBall from './contents/FalledBall';

export function App() {
  return (
    <>
      <div>
        <FalledBall />
      </div>
    </>
  )
}
```
```tsx:src/context/FalledBall.tsx
import { useAtom } from "jotai";
import { atomWithObservable } from "jotai/utils";
import RAPIER from '@dimforge/rapier2d';
import { map } from "rxjs/operators";
import { interval } from "rxjs";
import { Suspense } from 'preact/compat';
import ViewerBaseWrapper from "../components/viewer/BaseWrapper";

const SCALE = 100;
const LayerWidth = 500;
const LayerHeight = 500;
// 壁の設定
const deviderProps = {
  height: 10,
  width: LayerWidth,
  mass: 1,
  rotate:0
} as const

const simulaterProps = {
  ballRadius: 10,
  ball1: {
    // mass(質量)を設定すると、床の厚さ4でも1000からの落下に耐えられることが判明。床の厚さが2だといくらmassを大きくしても抜けてしまう
    position: { x: 0, y: 200}
  },
  ball2: {
    position: { x: 2, y: 100}
  },
  deviders: [
    // 床
    {
      ...deviderProps,
      position: { x: 0, y: -deviderProps.width/2 - deviderProps.height / 2 },
    },
    // 左の壁
    {
      ...deviderProps,
      position: { x: deviderProps.width / 2, y: 0 },
      rotate: -90,
    },
    // 右の壁
    {
      ...deviderProps,
      position: { x: -deviderProps.width / 2, y: 0 },
      rotate: 90
    }
  ]
}
type SimulaterProps = typeof simulaterProps;
  // 重力ベクトル (yのマイナス方向にかかる力)
  const gravity = { x: 0, y: -9.81 };
  const world = new RAPIER.World(gravity);

const toRadian = (degree:number) => degree * ( Math.PI / 180 )

// scale: 計算を早くするために除算する係数
const createSimulator = (scale: number, props: SimulaterProps)=>{

  //  壁と床を作る
  props.deviders.forEach(devider=>{
    world.createCollider(RAPIER.ColliderDesc.cuboid(devider.width/2/scale, devider.height/2/scale)
    .setMass(devider.mass)
    .setTranslation(devider.position.x/scale, devider.position.y/scale)
    .setRotation(toRadian(devider.rotate)));
  })


  // ボール(半径10px)
  const ballRadius = props.ballRadius/scale;
  const ballRigidBodyDesc = RAPIER.RigidBodyDesc.dynamic().setTranslation(props.ball1.position.x/scale, props.ball1.position.y/scale);
  const ballRigidBody = world.createRigidBody(ballRigidBodyDesc);
  const ball = RAPIER.ColliderDesc.ball(ballRadius/2)
    .setRestitution(0.7); // 反発係数
  world.createCollider(ball, ballRigidBody);
  // ボール二つ目
  const ball2 = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(props.ball2.position.x/scale, props.ball2.position.y/scale));
  world.createCollider(RAPIER.ColliderDesc.ball(ballRadius/2).setRestitution(0.7), ball2);
  console.log(world.colliders);
  const balls = [ballRigidBody, ball2]
  return {
    getBalls: ()=>balls,
    addBall: (position:{x:number;y:number})=>{
      const b = world.createRigidBody(
        RAPIER.RigidBodyDesc.dynamic()
                            .setTranslation(position.x/scale, position.y/scale)
                            .setAngvel(2)
      );
      world.createCollider(RAPIER.ColliderDesc.ball(ballRadius/2).setRestitution(0.7), b);
      balls.push(b);
    }
  };
}

const simulator = createSimulator(SCALE, simulaterProps);
const gameLoop = () => {
  world.step();
  const positions = simulator.getBalls().map(obj=>obj.translation());
  return positions;
};
const positionSubject = interval(16).pipe(map(() =>gameLoop()));
const positionAtom = atomWithObservable(() => positionSubject);

const positionNumFormat = (i:number)=>i.toString().padEnd(23,'0');
const Position = () => {
  const [[position]] = useAtom(positionAtom);
  return <div>x:{positionNumFormat(position.x)},y:{positionNumFormat(position.y)}</div>;
};

// SCALEで小さくしてシミュレーションした結果を描画用に引き延ばす
const positionToScale = (i:number)=> Math.floor(i * SCALE);

// 玉
const CircleDiv = ({radius,position}:{radius: number, position: {x:number;y:number}})=>{
 const diameter = radius * 2;
  return <div style={{
    position: 'absolute', border: 'solid 1px #000', backgroundColor: '#000', boxSizing: 'border-box', pointerEvents: 'none',
    width:`${diameter}px`,height:`${diameter}px`,borderRadius: '50%',
    transform: `translateX(${positionToScale(position.x) - radius}px) translateY(${- positionToScale(position.y) - radius - radius / 2}px)`
  }} />
}

// 壁
const RectDiv = (props: {width: number; height: number; position: {x:number;y:number}, rotate?:number})=> {
  const rotate = props.rotate ? `rotate(${props.rotate}deg)` : '';
  return <div style={{width: props.width, height: props.height, position:'absolute',
    boxSizing: 'border-box', pointerEvents: 'none', border: 'solid 1px #000', 
    transform: `translateX(${props.position.x - props.width / 2}px) translateY(${-props.position.y - props.height / 2 }px) ${rotate}`
    }} />
}

const Viewer = (props: SimulaterProps)=> {
  const [positions] = useAtom(positionAtom);

  return <ViewerBaseWrapper width={LayerWidth} height={LayerHeight}
                            onClick={(e)=>{
                              e.preventDefault();
                              e.stopPropagation();

                              console.log(e); 
                              simulator.addBall({x: e.offsetX - LayerWidth / 2, y: LayerHeight / 2 - e.offsetY})}}>
          {positions.map(position=><CircleDiv radius={props.ballRadius} position={position} />)}
          {props.deviders.map(prop => <RectDiv {...prop} />)}
        </ViewerBaseWrapper>
}
export default function FalledBallICreatable() {
  return (
    <>
      <div>
        <Suspense fallback="Loading...">
          <Viewer {...simulaterProps}  />
          <Position />
          <button onClick={()=>simulator.addBall({x: 0, y:200})}>ボールを追加</button>
        </Suspense>
      </div>
    </>
  )
}
```

```tsx:src/components/BaseWrapper.tsx
import { ComponentChildren, JSX } from 'preact';

const borderWidth = 2;

export default function ViewerBaseWrapper(props:{children: ComponentChildren, width?:number;height?:number; onClick?: JSX.MouseEventHandler<HTMLDivElement>}) {
  const {width: w = 500, height: h = 500}  = props;
  const corssBackgroundImage = `linear-gradient(to right, transparent,transparent ${w/2 - borderWidth/2}px, #000 , transparent ${w/2 + borderWidth/2}px, transparent)` +  `,`
                           + `linear-gradient(to top, transparent,transparent ${h/2 - borderWidth/2}px, #000 , transparent ${h/2 + borderWidth/2}px, transparent)`
                           ;
  return (
    <div style={
        { width: `${w}px`, height: `${h}px`, border: 'solid 1px #000',
          display: 'grid', placeItems:'center',
          backgroundImage: corssBackgroundImage
         }} onClick={props.onClick}>
      <div style={{width: '0', height: '0', overflow: 'visible', position: 'relative'}}>
        {props.children}
      </div>
    </div>
  )
}
```


</div></details>

## ボールを落とす

描画はpreact、状態管理はjotaiを利用した。

```tsx
import { useAtom } from "jotai";
import { atomWithObservable } from "jotai/utils";
import RAPIER from '@dimforge/rapier2d';
import { map } from "rxjs/operators";
import { interval } from "rxjs";
import { Suspense } from 'preact/compat';

const SCALE = 100;
const LayerWidth = 500;
const LayerHeight = 500;

// 壁との設定
const deviderProps = {
  height: 10,
  width: LayerWidth,
  mass: 1,
  rotate:0
} as const

const simulaterProps = {
  ballRadius: 10,
  ball1: {
    // mass(質量)を設定しないと床を突き抜けてしまいやすくなる。床の厚さが2だといくらmassを大きくしても抜けてしまう
    position: { x: 0, y: 200}
  },
  ball2: {
    position: { x: 2, y: 100}
  },
  deviders: [
    // 床
    {
      ...deviderProps,
      position: { x: 0, y: -deviderProps.width/2 - deviderProps.height / 2 },
    },
    // 左の壁
    {
      ...deviderProps,
      position: { x: deviderProps.width / 2, y: 0 },
      rotate: -90,
    },
    // 右の壁
    {
      ...deviderProps,
      position: { x: -deviderProps.width / 2, y: 0 },
      rotate: 90
    }
  ]
}
type SimulaterProps = typeof simulaterProps;
  // 重力ベクトル (yのマイナス方向にかかる力)
  const gravity = { x: 0, y: -9.81 };
  const world = new RAPIER.World(gravity);

const toRadian = (degree:number) => degree * ( Math.PI / 180 )

// scale: 計算を早くするために除算する係数
const createSimulator = (scale: number, props: SimulaterProps)=>{

  //  壁と床を作る
  props.deviders.forEach(devider=>{
    world.createCollider(RAPIER.ColliderDesc.cuboid(devider.width/2/scale, devider.height/2/scale)
    .setMass(devider.mass)
    .setTranslation(devider.position.x/scale, devider.position.y/scale)
    .setRotation(toRadian(devider.rotate)));
  })


  // ボール(半径10px)
  const ballRadius = props.ballRadius/scale;
  const balls: RAPIER.RigidBody[] = []
  return {
    getBalls: ()=>balls,
    addBall: (position:{x:number;y:number})=>{
      const b = world.createRigidBody(
        RAPIER.RigidBodyDesc.dynamic()
                            .setTranslation(position.x/scale, position.y/scale)
                            .setAngvel(2)
      );
      world.createCollider(RAPIER.ColliderDesc.ball(ballRadius/2).setRestitution(0.7), b);
      balls.push(b);
    }
  };
}

// シミュレーションを行い、新しい場所を取得
const simulator = createSimulator(SCALE, simulaterProps);
const gameLoop = () => {
  world.step();
  const positions = simulator.getBalls().map(obj=>obj.translation());
  return positions;
};

// 16フレームでループを実施
const positionSubject = interval(16).pipe(map(() =>gameLoop()));
const positionAtom = atomWithObservable(() => positionSubject);
```

## 落としたボールを描画
マウスクリックしたところからボールを落とすイベントを設定している。
offsetXを使っているため、ボールや壁をクリックしたときに場所がずれる問題があるため、
ボールや壁には`pointerEvents: 'none'`を設定してマウスイベント対象外としている。

```tsx
const borderWidth = 2;

function ViewerBaseWrapper(props:{children: ComponentChildren, width?:number;height?:number; onClick?: JSX.MouseEventHandler<HTMLDivElement>}) {
  const {width: w = 500, height: h = 500}  = props;
  const corssBackgroundImage = `linear-gradient(to right, transparent,transparent ${w/2 - borderWidth/2}px, #000 , transparent ${w/2 + borderWidth/2}px, transparent)` +  `,`
                           + `linear-gradient(to top, transparent,transparent ${h/2 - borderWidth/2}px, #000 , transparent ${h/2 + borderWidth/2}px, transparent)`
                           ;
  return (
    <div style={
        { width: `${w}px`, height: `${h}px`, border: 'solid 1px #000',
          display: 'grid', placeItems:'center',
          backgroundImage: corssBackgroundImage
         }} onClick={props.onClick}>
      <div style={{width: '0', height: '0', overflow: 'visible', position: 'relative'}}>
        {props.children}
      </div>
    </div>
  )
}


const positionNumFormat = (i:number)=>i.toString().padEnd(23,'0');
const Position = () => {
  const [[position]] = useAtom(positionAtom);
  return <div>x:{positionNumFormat(position.x)},y:{positionNumFormat(position.y)}</div>;
};

// SCALEで小さくしてシミュレーションした結果を描画用に引き延ばす
const positionToScale = (i:number)=> Math.floor(i * SCALE);

// 玉
const CircleDiv = ({radius,position}:{radius: number, position: {x:number;y:number}})=>{
 const diameter = radius * 2;
  return <div style={{
    position: 'absolute', border: 'solid 1px #000', backgroundColor: '#000', boxSizing: 'border-box', pointerEvents: 'none',
    width:`${diameter}px`,height:`${diameter}px`,borderRadius: '50%',
    transform: `translateX(${positionToScale(position.x) - radius}px) translateY(${- positionToScale(position.y) - radius - radius / 2}px)`
  }} />
}

// 壁・床
const RectDiv = (props: {width: number; height: number; position: {x:number;y:number}, rotate?:number})=> {
  const rotate = props.rotate ? `rotate(${props.rotate}deg)` : '';
  return <div style={{width: props.width, height: props.height, position:'absolute',
    boxSizing: 'border-box', pointerEvents: 'none', border: 'solid 1px #000', 
    transform: `translateX(${props.position.x - props.width / 2}px) translateY(${-props.position.y - props.height / 2 }px) ${rotate}`
    }} />
}

const Viewer = (props: SimulaterProps)=> {
  const [positions] = useAtom(positionAtom);

  return <ViewerBaseWrapper width={LayerWidth} height={LayerHeight}
                            onClick={(e)=>{
                              e.preventDefault();
                              e.stopPropagation();
                              simulator.addBall({x: e.offsetX - LayerWidth / 2, y: LayerHeight / 2 - e.offsetY})}}>
          {positions.map(position=><CircleDiv radius={props.ballRadius} position={position} />)}
          {props.deviders.map(prop => <RectDiv {...prop} />)}
        </ViewerBaseWrapper>
}
export default function FalledBallICreatable() {
  return (
    <>
      <div>
        <Suspense fallback="Loading...">
          <Viewer {...simulaterProps}  />
          <Position />
          <button onClick={()=>simulator.addBall({x: 0, y:200})}>ボールを追加</button>
        </Suspense>
      </div>
    </>
  )
}


```

## 実践

クリックしたところから球が落ちる。
連打すると、時々床を突き抜けて落ちていく現象を確認できた。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99876/164b38e5-fa80-f1f5-27c8-e88e6275a991.png)


## 参考

[Rapier](https://rapier.rs/)
[Rustの物理演算エンジンRapierを使う](https://note.com/build_a_computer/n/n40aa4eba99a2)
[【JavaScript】物理エンジンまとめ](https://qiita.com/wizroot/items/a3e73597b21465c4719a)
[p5.js と物理演算エンジン「Matter.js」の組み合わせをお試し](https://qiita.com/youtoy/items/0b16f6fb3a1c76d64b68)
[物理演算エンジン matter.js で簡単な機構を作る](https://qiita.com/yubais/items/ac6a117b20b7cfb00910)
[collision-detection-not-aligning-properly-when-rendering-rapier-js-to-the-dom](https://stackoverflow.com/questions/73877287/collision-detection-not-aligning-properly-when-rendering-rapier-js-to-the-dom)

<details><div>

```tsx
import { useAtom } from "jotai";
import { atomWithObservable } from "jotai/utils";
import RAPIER from '@dimforge/rapier2d';
import { map } from "rxjs/operators";
import { interval } from "rxjs";
import { Suspense } from 'preact/compat';
import ViewerBaseWrapper from "../components/viewer/BaseWrapper";

const SCALE = 100;
const LayerWidth = 500;
const LayerHeight = 500;
// 壁の設定
const deviderProps = {
  height: 40,
  width: LayerWidth,
  mass: 1,
  rotate:0
} as const

const simulaterProps = {
  textFrame: {
    fontSize: 16,
    width: 200,
    height: 50,
    density: 1,
    mass: 1,
    text: 'てすと'
  },
  deviders: [
    // 床
    {
      ...deviderProps,
      position: { x: 0, y: -deviderProps.width/2 - deviderProps.height / 2 },
    },
    // 左の壁
    {
      ...deviderProps,
      position: { x: deviderProps.width / 2, y: 0 },
      rotate: -90,
    },
    // 右の壁
    {
      ...deviderProps,
      position: { x: -deviderProps.width / 2, y: 0 },
      rotate: 90
    }
  ]
}
type SimulaterProps = typeof simulaterProps;
  // 重力ベクトル (yのマイナス方向にかかる力)
  const gravity = { x: 0, y: 0 };
  const world = new RAPIER.World(gravity);

const toRadian = (degree:number) => degree * ( Math.PI / 180 )
const toDegree = (radian:number) => radian * (  180 / Math.PI )

// scale: 計算を早くするために除算する係数
const createSimulator = (scale: number, props: SimulaterProps)=>{

  //  壁と床を作る
  props.deviders.forEach(devider=>{
    world.createCollider(RAPIER.ColliderDesc.cuboid(devider.width/2/scale, devider.height/2/scale)
    .setMass(devider.mass)
    .setTranslation(devider.position.x/scale, devider.position.y/scale)
    .setRotation(toRadian(devider.rotate)));
  })


  const texts:{ rigid: RAPIER.RigidBody, collider: RAPIER.ColliderDesc, text: string }[] = []
  return {
    getTexts: ()=>texts,
    addText: (position:{x:number;y:number})=>{
      const b = world.createRigidBody(
        RAPIER.RigidBodyDesc.dynamic()
                            .setTranslation(position.x/scale, position.y/scale)
                            .setAngvel(0)
                            .setLinvel(-1,0)
        );
      // cuboidは長さ・高さを半分にして作成
      const hw = props.textFrame.fontSize*props.textFrame.text.length/2/scale;
      const hh = props.textFrame.fontSize/2/scale;
      const c = RAPIER.ColliderDesc.cuboid(hw,hh)
                                   .setMass(props.textFrame.mass)
                                   .setDensity(props.textFrame.density)
                                   .setRestitution(0.7)
                                   .setActiveEvents(RAPIER.ActiveEvents.COLLISION_EVENTS)
                                   ;
      // シミュレーション開始  
      world.createCollider(c, b);             ;

      texts.push({rigid: b, collider: c, text: props.textFrame.text });
    }
  };
}

const simulator = createSimulator(SCALE, simulaterProps);
const eventQueue = new RAPIER.EventQueue(true);
const gameLoop = () => {
  world.step(eventQueue);
  
  const items = simulator.getTexts().map(obj=>({
    position: obj.rigid.translation(), 
    rotation: obj.rigid.rotation(),
    width: ( obj.collider.shape as any).halfExtents.x * 2,
    height: ( obj.collider.shape as any).halfExtents.y * 2,
    text: `${obj.text}` }));
    eventQueue.drainCollisionEvents((handle1, handle2, started) => {
      console.log('drainCollisionEvents', {handle1, handle2, started});
      // RigidBodyは取得できない
      const b1 = world.getRigidBody(handle1);
      const b2 = world.getRigidBody(handle2);
      console.log({b1, b2})
      // Colliderは取得できる
      const c1 = world.getCollider(handle1);
      const c2 = world.getCollider(handle2);
      console.log({c1, c2})
      
    });
  
  // 今回はCONTACT_FORCE_EVENTSを有効にしていないので、発生しない
  // eventQueue.drainContactForceEvents(event => {
  //     let handle1 = event.collider1(); // Handle of the first collider involved in the event.
  //     let handle2 = event.collider2(); // Handle of the second collider involved in the event.
  //     console.log('drainContactForceEvents', {handle1, handle2})
  // });
  return items;
};
const itemSubject = interval(16).pipe(map(() =>gameLoop()));
const rigidBodyAtom = atomWithObservable(() => itemSubject);


// SCALEで小さくしてシミュレーションした結果を描画用に引き延ばす
const positionToScale = (i:number)=> i * SCALE;

// 壁
const RectDiv = (props: {width: number; height: number; position: {x:number;y:number}, rotate?:number})=> {
  const rotate = props.rotate ? `rotate(${props.rotate}deg)` : '';
  return <div style={{width: props.width, height: props.height, position:'absolute',
    boxSizing: 'border-box', pointerEvents: 'none', borderTop: 'solid 1px #000', 
    transform: `translateX(${props.position.x - props.width / 2}px) translateY(${-props.position.y - props.height / 2 }px) ${rotate}`
    }} />
}
// 文字
const RectTextDiv = (props: { fontSize: number; text:string; width: number; height: number; position: {x:number;y:number}, rotation?:number})=> {
  const rotate = props.rotation ? `rotate(${toDegree(props.rotation)}deg)` : '';
  const width = positionToScale(props.width);
  const height = positionToScale(props.height);
  return <div style={{
    fontSize: `${props.fontSize}px`,
    width: width, height: height, position:'absolute',
    boxSizing: 'border-box', pointerEvents: 'none', border: 'none', 
    transform: `translateX(${positionToScale(props.position.x) - width / 2}px) translateY(${-positionToScale(props.position.y) - height / 2 }px) ${rotate}`
    }}>{props.text}</div>
}

const Viewer = (props: SimulaterProps)=> {
  const [items] = useAtom(rigidBodyAtom);

  return <ViewerBaseWrapper width={LayerWidth} height={LayerHeight}
                            onClick={(e)=>{
                              e.preventDefault();
                              e.stopPropagation();

                     
                              simulator.addText({x: e.offsetX - LayerWidth / 2, y: LayerHeight / 2 - e.offsetY})}}>
          {items.map(item=><RectTextDiv fontSize={props.textFrame.fontSize} {...item} />)}
          {props.deviders.map(prop => <RectDiv {...prop} />)}
        </ViewerBaseWrapper>
}
export default function FlowText() {
  return (
    <>
      <div>
        <Suspense fallback="Loading...">
          <Viewer {...simulaterProps}  />

          <button onClick={()=>simulator.addText({x: 0, y:200})}>テキストを追加</button>
        </Suspense>
      </div>
    </>
  )
}
```

</div></details>