概要
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>
</>
)
}
実践
クリックしたところから球が落ちる。
連打すると、時々床を突き抜けて落ちていく現象を確認できた。
参考
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>
</>
)
}