0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-07-01

概要

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

準備

ビルドにはvite を使った。 WASMの扱いには追加で設定が必要だった。参考

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()
  ],
})
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"
  }
}
src/main.tsx
import { render } from 'preact'
import { App } from './app.tsx'

import './index.css'

render(<App />, document.getElementById('app') as HTMLElement)
src/app.tsx
import './app.css'
import FalledBall from './contents/FalledBall';

export function App() {
  return (
    <>
      <div>
        <FalledBall />
      </div>
    </>
  )
}
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>
    </>
  )
}
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>
  )
}

ボールを落とす

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

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'を設定してマウスイベント対象外としている。

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

参考

Rapier
Rustの物理演算エンジンRapierを使う
【JavaScript】物理エンジンまとめ
p5.js と物理演算エンジン「Matter.js」の組み合わせをお試し
物理演算エンジン matter.js で簡単な機構を作る
collision-detection-not-aligning-properly-when-rendering-rapier-js-to-the-dom

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>
    </>
  )
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?