Closed83

Next.js / React で React Three Cannon を使って物理演算を行う

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

このスクラップでは React Three Cannon を使って Web ページ上で基本的な物理演算を行い、演算結果を React Three Fiber を使って 3D 表示するまでの過程を記録していく。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ワークスペースの作成

前のロボットアームから初めても良いがごちゃごちゃになりそうなので新たにワークスペースを作る。

コマンド
npx create-next-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*" \
  --use-npm \
  hello-physics
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

箱の表示

src/app/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
src/app/layout.tsx
import "./globals.css";

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
src/app/page.tsx
"use client";

import { Box, OrbitControls, PerspectiveCamera } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import Image from "next/image";

export default function Home() {
  const defaultMaterial = <meshPhongMaterial></meshPhongMaterial>;

  return (
    <main className="container mx-auto">
      <h1 className="mt-4 mb-4 text-4xl">Hello Physics</h1>
      <Canvas style={{ width: "50%", height: "50vh" }}>
        <OrbitControls></OrbitControls>
        <PerspectiveCamera
          makeDefault
          position={[0, 0, 10]}
        ></PerspectiveCamera>
        <ambientLight intensity={0.1}></ambientLight>
        <pointLight position={[10, 10, 10]}></pointLight>
        <Box position={[-1.2, 0, 0]}>{defaultMaterial}</Box>
        <Box position={[1.2, 0, 0]}>{defaultMaterial}</Box>
      </Canvas>
    </main>
  );
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

5/24 (水) はここまで

なんか画像を表示するだけで終わってしまった。

ここまでの作業時間は 1 時間。

生の Three.js と cannon-es を使った方が良いのではというもやもやを抱えながら作業している。

もしかしたらこのままクローズするかも知れない。

せめてあと 30 分くらいやって Getting Started くらいは終わらしてからクローズしよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

クローズするのはもったいないかも

Fiber + cannon-es を組み合わせるというやり方も面白そうなのでその方向性で続けてみようかな。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

React Three Cannon が合わなかった理由

Getting Started でコード自体は動作するが型エラーが出たのでこれでは先が思いやられると感じた。

src/app/page.tsx(抜粋)
function World() {
  const defaultMaterial = <meshPhongMaterial></meshPhongMaterial>;
  const [ref, api] = useBox(() => ({ mass: 1 }));

  return (
    <>
      <Box ref={ref} position={[-1.2, 0, 0]}>
        {defaultMaterial}
      </Box>
      <Box position={[1.2, 0, 0]}>{defaultMaterial}</Box>
    </>
  );
}

エラーメッセージ

Type 'RefObject<Object3D<Event>>' is not assignable to type 'Ref<Mesh<BufferGeometry<NormalBufferAttributes>, Material | Material[]>> | undefined'.
Type 'RefObject<Object3D<Event>>' is not assignable to type 'RefObject<Mesh<BufferGeometry<NormalBufferAttributes>, Material | Material[]>>'.
Type 'Object3D<Event>' is missing the following properties from type 'Mesh<BufferGeometry<NormalBufferAttributes>, Material | Material[]>': isMesh, geometry, material, updateMorphTargets, getVertexPosition

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

5/25 (木) はここまで

無為に 1 時間が流れた気がする、昨日からの累計では 2 時間か。

とりあえず useRef() を使った方が良いかも知れないと感じた。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

やっぱり cannon-es を使うのは大変かも知れない

Three.js + cannon-es か React Three Fiber + React Three Cannon の組み合わせでどちらか統一した方が良いかも知れない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ソースコードの復帰

src/app/page.tsx
"use client";

import { Physics, useBox } from "@react-three/cannon";
import { Box, OrbitControls, PerspectiveCamera } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";

export default function Home() {
  return (
    <main className="container mx-auto">
      <h1 className="mt-4 mb-4 text-4xl">Hello Physics</h1>
      <Canvas style={{ width: "50%", height: "50vh" }}>
        <OrbitControls></OrbitControls>
        <PerspectiveCamera
          makeDefault
          position={[0, 0, 10]}
        ></PerspectiveCamera>
        <ambientLight intensity={0.1}></ambientLight>
        <pointLight position={[10, 10, 10]}></pointLight>
        <Physics>
          <World></World>
        </Physics>
      </Canvas>
    </main>
  );
}

function World() {
  const defaultMaterial = <meshPhongMaterial></meshPhongMaterial>;
  const [ref, api] = useBox(() => ({ mass: 1 }));

  return (
    <>
      <Box ref={ref} position={[-1.2, 0, 0]}>
        {defaultMaterial}
      </Box>
      <Box position={[1.2, 0, 0]}>{defaultMaterial}</Box>
    </>
  );
}

エラーメッセージ

Type 'RefObject<Object3D<Event>>' is not assignable to type 'Ref<Mesh<BufferGeometry<NormalBufferAttributes>, Material | Material[]>> | undefined'.
Type 'RefObject<Object3D<Event>>' is not assignable to type 'RefObject<Mesh<BufferGeometry<NormalBufferAttributes>, Material | Material[]>>'.
Type 'Object3D<Event>' is missing the following properties from type 'Mesh<BufferGeometry<NormalBufferAttributes>, Material | Material[]>': isMesh, geometry, material, updateMorphTargets, getVertexPosition

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

tsconfig.json の比較

tsconfig.json(本プロジェクト)
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
tsconfig.json(公式 Examples)
{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": "./src",
    "declaration": true,
    "emitDeclarationOnly": true,
    "esModuleInterop": true,
    "incremental": true,
    "jsx": "react-jsx",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "module": "esnext",
    "moduleResolution": "node",
    "noImplicitThis": false,
    "outDir": "dist",
    "pretty": true,
    "removeComments": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "esnext",
    "types": ["vite/client"]
  },
  "exclude": ["node_modules"],
  "include": ["./src", "vite.config.ts"]
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

エラーメッセージが消えた!

src/app/page.tsx(抜粋)
function World() {
  const defaultMaterial = <meshPhongMaterial></meshPhongMaterial>;
  const [ref, api] = useBox(
    () => ({ mass: 1, position: [-1.2, 0, 0] }),
    useRef<Mesh>(null) // ここがポイントです。
  );

  return (
    <>
      <Box ref={ref}>{defaultMaterial}</Box>
      <Box position={[1.2, 0, 0]}>{defaultMaterial}</Box>
    </>
  );
}

下記が参考になった。

https://github.com/pmndrs/use-cannon/blob/master/packages/react-three-cannon-examples/src/demos/Pingpong/index.tsx#L61-L68

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

5/25 (木) おかわり 2 回目

src/app/page.tsx
"use client";

import { Physics, useBox, usePlane } from "@react-three/cannon";
import {
  Box,
  OrbitControls,
  PerspectiveCamera,
  Plane,
} from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { useRef } from "react";
import { Mesh } from "three";

export default function Home() {
  return (
    <main className="container mx-auto">
      <h1 className="mt-4 mb-4 text-4xl">Hello Physics</h1>
      <Canvas style={{ width: "50%", height: "50vh" }}>
        <OrbitControls></OrbitControls>
        <PerspectiveCamera
          makeDefault
          position={[0, 10, 10]}
        ></PerspectiveCamera>
        <ambientLight intensity={0.1}></ambientLight>
        <pointLight position={[10, 10, 10]}></pointLight>
        <Physics>
          <World></World>
        </Physics>
      </Canvas>
    </main>
  );
}

function World() {
  const defaultMaterial = <meshPhongMaterial></meshPhongMaterial>;
  const [boxRef] = useBox(
    () => ({ args: [1, 1, 1], mass: 1, position: [0, 5, 0] }),
    useRef<Mesh>(null)
  );

  const planeSize = 10;
  const planeThickness = 0.01;
  const [planeRef, api] = useBox(
    () => ({
      args: [planeSize, planeThickness, planeSize],
      position: [0, -5, 0],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  return (
    <>
      <Box args={[1, 1, 1]} ref={boxRef}>
        {defaultMaterial}
      </Box>
      <Box args={[planeSize, planeThickness, planeSize]} ref={planeRef}>
        {defaultMaterial}
      </Box>
    </>
  );
}


ようやく箱が地面に落下していった

ref を使っても位置と回転しか反映されないので Scale は手動で合わせる必要がある。

30 分くらい使ったので今日だけで 2 時間、合計で 3 時間か。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

はじめの一歩

src/app/page.tsx
"use client";

import { Physics } from "@react-three/cannon";
import { Canvas } from "@react-three/fiber";

export default function Home() {
  return (
    <main className="container mx-auto">
      <h1 className="mt-4 mb-4 text-4xl">Hello Physics</h1>
      <Canvas
        camera={{ far: 100, near: 1, position: [-25, 20, 25], zoom: 25 }}
        orthographic
        shadows
        style={{ cursor: "none", width: "70%", height: "70vh" }}
      >
        <color attach="background" args={["#171720"]}></color>
        <fog attach="fog" args={["#171720", 20, 70]}></fog>
        <ambientLight intensity={0.2}></ambientLight>
        <pointLight
          position={[-10, -10, -10]}
          color="red"
          intensity={1.5}
        ></pointLight>
        <Physics
          iterations={15}
          gravity={[0, -200, 0]}
          allowSleep={false}
        ></Physics>
      </Canvas>
    </main>
  );
}


今のところ黒い画面が表示されるだけ

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

はじめに Ragdoll に手を出さない方が良い

src/app/page.tsx(抜粋)
// function Ragdoll() {
//   const mouth = useRef<Mesh>(null);
//   const eyes = useRef<Group>(null);

//   useFrame(({ clock }) => {
//     if (!eyes.current || !mouth.current) {
//       return;
//     }

//     eyes.current.position.y = Math.sin(clock.getElapsedTime() * 1) * 0.06;
//     mouth.current.scale.y = (1 + Math.sin(clock.getElapsedTime())) * 1.5;
//   });

//   return <></>;
// }

// type BoxProps = Omit<MeshProps, "args"> &
//   Pick<BoxBufferGeometryProps, "args"> &
//   Pick<MeshStandardMaterialProps, "color" | "opacity" | "transparent">;

// type BodyPartProps = BoxProps & {
//   config?: ConeTwistConstraintOpts;
//   name: string;
//   render?: ReactNode;
// };

// function BodyPart({
//   children,
//   config = {},
//   name,
//   render,
//   ...props
// }: BodyPartProps): JSX.Element {
//   return <></>;
// }
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Plane の追加

src/app/page.tsx(末尾に追加)
function Plane(props: PlaneProps) {
  const [ref] = usePlane(() => ({ ...props }), useRef<Mesh>(null));

  return (
    <mesh ref={ref} receiveShadow>
      <planeBufferGeometry args={[1000, 1000]}></planeBufferGeometry>
      <meshStandardMaterial color="#171720"></meshStandardMaterial>
    </mesh>
  );
}

次に <Physics>...<Physics> に下記を追加する。

src/app/page.tsx(<Physics>...<Physics> 内に追加)
<Plane position={[0, -5, 0]} rotation={[-Math.PI / 2, 0, 0]}></Plane>


color を変えるなどすると見えるようになる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

椅子の背もたれができた

src/app/page.tsx
type BoxProps = Omit<MeshProps, "args"> &
  Pick<BoxBufferGeometryProps, "args"> &
  Pick<MeshStandardMaterialProps, "color" | "opacity" | "transparent">;

// eslint-disable-next-line react/display-name
const Box = forwardRef<Mesh, BoxProps>(
  (
    {
      args = [1, 1, 1],
      children,
      color = "white",
      opacity = 1,
      transparent = false,
      ...props
    },
    ref
  ) => {
    return (
      <mesh castShadow receiveShadow ref={ref} {...props}>
        <boxBufferGeometry args={args}></boxBufferGeometry>
        <meshStandardMaterial
          color={color}
          opacity={opacity}
          transparent={transparent}
        ></meshStandardMaterial>
      </mesh>
    );
  }
);

function Chair() {
  const [ref] = useCompoundBody(
    () => ({
      mass: 1,
      position: [-6, 0, 0],
      shapes: [
        { args: [1.5, 1.5, 0.25], mass: 1, position: [0, 0, 0], type: "Box" },
        {
          args: [1.5, 0.25, 1.5],
          mass: 1,
          position: [0, -1.75, 1.25],
          type: "Box",
        },
      ],
      type: "Dynamic",
    }),
    useRef<Group>(null)
  );

  // const bind = useDragConstraint(ref);

  return (
    <group ref={ref}>
      <Box position={[0, 0, 0]} scale={[3, 3, 0.5]}></Box>
    </group>
  );
}


背もたれが落下した後の様子

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

5/26 (金) はここまで

TypeScript の面でもめちゃくちゃ学ぶことが多い。

使いこなせたら Web 3D アプリが作れそうで夢がある。

途中で挫折しなくて良かった。

今日の学習時間は 1 時間なので累計では 4 時間。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

5/26 (金) おかわり

src/app/page.tsx(抜粋)
function Chair() {
  const [ref] = useCompoundBody(
    () => ({
      mass: 1,
      position: [-6, 0, 0],
      shapes: [
        { args: [1.5, 1.5, 0.25], mass: 1, position: [0, 0, 0], type: "Box" },
        {
          args: [1.5, 0.25, 1.5],
          mass: 1,
          position: [0, -1.75, 1.25],
          type: "Box",
        },
        {
          args: [0.25, 1.5, 0.25],
          mass: 10,
          position: [5 + -6.25, -3.5, 0],
          type: "Box",
        },
        {
          args: [0.25, 1.5, 0.25],
          mass: 10,
          position: [5 + -3.75, -3.5, 0],
          type: "Box",
        },
        {
          args: [0.25, 1.5, 0.25],
          mass: 10,
          position: [5 + -6.25, -3.5, 2.5],
          type: "Box",
        },
        {
          args: [0.25, 1.5, 0.25],
          mass: 10,
          position: [5 + -3.75, -3.5, 2.5],
          type: "Box",
        },
      ],
      type: "Dynamic",
    }),
    useRef<Group>(null)
  );

  // const bind = useDragConstraint(ref);

  return (
    <group ref={ref}>
      <Box position={[0, 0, 0]} scale={[3, 3, 0.5]}></Box>
      <Box position={[0, -1.75, 1.25]} scale={[3, 0.5, 3]}></Box>
      <Box position={[5 + -6.25, -3.5, 0]} scale={[0.5, 3, 0.5]}></Box>
      <Box position={[5 + -3.75, -3.5, 0]} scale={[0.5, 3, 0.5]}></Box>
      <Box position={[5 + -6.25, -3.5, 2.5]} scale={[0.5, 3, 0.5]}></Box>
      <Box position={[5 + -3.75, -3.5, 2.5]} scale={[0.5, 3, 0.5]}></Box>
    </group>
  );
}


椅子が完成した

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テーブルの追加

src/app/page.tsx(抜粋)
function Table() {
  const [seat] = useBox(
    () => ({
      args: [2.5, 0.25, 2.5],
      position: [9, -0.8, 0],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  const [leg1] = useBox(
    () => ({
      args: [0.25, 2, 0.25],
      position: [7.2, -3, 1.8],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  const [leg2] = useBox(
    () => ({
      args: [0.25, 2, 0.25],
      position: [10.8, -3, 1.8],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  const [leg3] = useBox(
    () => ({
      args: [0.25, 2, 0.25],
      position: [7.2, -3, -1.8],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  const [leg4] = useBox(
    () => ({
      args: [0.25, 2, 0.25],
      position: [10.8, -3, -1.8],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  return (
    <>
      <Box scale={[5, 0.5, 5]} ref={seat}></Box>
      <Box scale={[0.5, 4, 0.5]} ref={leg1}></Box>
      <Box scale={[0.5, 4, 0.5]} ref={leg2}></Box>
      <Box scale={[0.5, 4, 0.5]} ref={leg3}></Box>
      <Box scale={[0.5, 4, 0.5]} ref={leg4}></Box>
    </>
  );
}


机が完成した

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ランプの追加

src/app/page.tsx(抜粋)
const Lamp = () => {
  const light = useRef<SpotLight>(null);
  const [fixed] = useSphere(
    () => ({
      args: [1],
      position: [0, 5, 0],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  const [lamp] = useBox(
    () => ({
      angularDamping: 0.99,
      args: [1, 0, 5],
      linearDamping: 0.9,
      mass: 1,
      position: [0, 16, 0],
    }),
    useRef<Mesh>(null)
  );

  usePointToPointConstraint(fixed, lamp, {
    pivotA: [0, 0, 0],
    pivotB: [0, 2, 0],
  });

  // const bind = useDragConstraint(lamp);

  return (
    <>
      <mesh ref={lamp}>
        <coneBufferGeometry
          attach="geometry"
          args={[2, 2.5, 32]}
        ></coneBufferGeometry>
        <meshStandardMaterial attach="material"></meshStandardMaterial>
        <pointLight intensity={10} distance={5}></pointLight>
        <spotLight
          ref={light}
          position={[0, 20, 0]}
          angle={0.4}
          penumbra={1}
          intensity={0.6}
          castShadow
        ></spotLight>
      </mesh>
    </>
  );
};


angularDamping を 1 未満に変更する必要がある

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

カーソルの表示

src/app/page.tsx(末尾に追記)
const Cursor = () => {
  const [ref, api] = useSphere(
    () => ({
      args: [0.5],
      position: [0, 0, 10000],
      type: "Static",
    }),
    cursor
  );

  useFrame(({ mouse, viewport: { height, width } }) => {
    const x = mouse.x * width;
    const y = (mouse.y * height) / 1.9 + -x / 3.5;
    api.position.set(x / 1.4, y, 0);
  });

  return (
    <mesh ref={ref}>
      <sphereBufferGeometry args={[0.5, 32, 32]}></sphereBufferGeometry>
      <meshBasicMaterial
        fog={false}
        depthTest={false}
        transparent
        opacity={0.5}
      ></meshBasicMaterial>
    </mesh>
  );
};


カーソルが表示された

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

制約の追加

src/app/page.tsx(末尾に追記)
function useDragConstraint(child: RefObject<Object3D>) {
  const [, , api] = usePointToPointConstraint(cursor, child, {
    pivotA: [0, 0, 0],
    pivotB: [0, 0, 0],
  });

  useEffect(() => void api.disable(), []);

  const onPointerDown = useCallback((e: ThreeEvent<PointerEvent>) => {
    e.stopPropagation();
    //@ts-expect-error Investigate proper types here.
    e.target.setPointerCapture(e.pointerId);
    api.enable();
  }, []);

  const onPointerUp = useCallback(() => api.disable(), []);
  return { onPointerDown, onPointerUp };
}


ランプや椅子がドラッグ&ドロップできるようになった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

React Error

VSCode で警告が表示されたので無効にした。

src/app/page.tsx(先頭に追記)
/* eslint-disable react-hooks/exhaustive-deps */
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/1 (木) はここから

前回の「ここまで」を書き忘れたけど多分 1 時間稼働で累計 6 時間になったのではないかと思う。

今日は HingeMotor をやろうと思ったけどこの辺りで実務のトライアルをやってみて学びが足りなかったら再び写経をしようと思う。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

新しいワークスペースの作成

コマンド
# cd ~/workspace
npx create-next-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*" \
  --use-npm \
  robot-arm-physics
npm install three @types/three @react-three/fiber @react-three/drei @react-three/cannon
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ここまで 1 時間

Hello World レベルのサンプルを作るのに 1 時間もかかってしまった、累計では 7 時間。

src/app/page.tsx
"use client";

import { Physics, useBox, usePlane } from "@react-three/cannon";
import {
  Box,
  OrbitControls,
  PerspectiveCamera,
  Plane,
} from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { FC, useRef } from "react";
import { Mesh } from "three";

export default function Home() {
  return (
    <main className="mx-auto container">
      <h1 className="text-4xl mt-4 mb-4">Robot arm physics</h1>
      <Canvas
        style={{
          width: "70%",
          height: "70vh",
        }}
      >
        <Physics>
          <OrbitControls></OrbitControls>
          <ambientLight intensity={0.2}></ambientLight>
          <pointLight position={[5, 10, 5]} intensity={0.6}></pointLight>
          <PerspectiveCamera
            makeDefault
            position={[5, 5, 5]}
          ></PerspectiveCamera>
          <MyBox mass={1} position={[0, 0.5, 0]} scale={[1, 1, 1]}></MyBox>
          <MyBox mass={1} position={[-0.5, 3, 0]} scale={[1, 1, 1]}></MyBox>
          <MyPlane scale={[10, 10, 10]} position={[0, 0, 0]}></MyPlane>
        </Physics>
      </Canvas>
    </main>
  );
}

const MyBox: FC<{
  mass: number;
  position: [number, number, number];
  scale: [number, number, number];
}> = ({ mass, position, scale }) => {
  const [ref] = useBox(
    () => ({
      args: scale,
      position,
      mass,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Box ref={ref} scale={scale}>
      <meshStandardMaterial></meshStandardMaterial>
    </Box>
  );
};

const MyPlane: FC<{
  position: [number, number, number];
  scale: [number, number, number];
}> = ({ position, scale }) => {
  const [ref] = usePlane(
    () => ({
      rotation: [-Math.PI / 2, 0, 0],
      args: scale,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Plane ref={ref} scale={scale}>
      <meshStandardMaterial></meshStandardMaterial>
    </Plane>
  );
};


Three と Connon のスケールは同じで良かったっけ?

たった 2 日やらなかっただけで忘れまくっている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Static と Kinematic の違いって何だ?

Static(訳:静的)

https://schteppe.github.io/cannon.js/docs/classes/Body.html#property_STATIC

A static body does not move during simulation and behaves as if it has infinite mass. Static bodies can be moved manually by setting the position of the body. The velocity of a static body is always zero. Static bodies do not collide with other static or kinematic bodies.

Static な物体はシミュレーションの間は動かず、無限の質量を持っているように振る舞います。位置を設定することで手動で動かすことができます。速度は常にゼロです。Static なボディは他の Static / Kinematic なボディと接触しません。

Kinematic(訳:運動学的)

https://schteppe.github.io/cannon.js/docs/classes/Body.html#property_KINEMATIC

A kinematic body moves under simulation according to its velocity. They do not respond to forces. They can be moved manually, but normally a kinematic body is moved by setting its velocity. A kinematic body behaves as if it has infinite mass. Kinematic bodies do not collide with other static or kinematic bodies.

Kinematic なボディはシミュレーションの間に速度に従って動きます。力には反応しません。手動で動かすことができますが、通常は速度を設定することで動かします。無限の質量を持っているように振る舞います。Kinematic なボディは他の Static / Kinematic なボディと接触しません。

どちらを使えば良い?

位置をコントールしたい場合は Static が良さそう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

MyBox & MyPlane のリファクタリング

src/app/page.tsx(抜粋)
type MyBoxProps = BoxProps;

const MyBox: FC<MyBoxProps> = ({ args, ...rest }) => {
  const [ref] = useBox(
    () => ({
      args,
      ...rest,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Box ref={ref} scale={args}>
      <meshStandardMaterial></meshStandardMaterial>
    </Box>
  );
};

type MyPlaneProps = PlaneProps & Pick<MeshProps, "scale">;

const MyPlane: FC<MyPlaneProps> = ({ scale, ...rest }) => {
  const [ref] = usePlane(
    () => ({
      ...rest,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Plane ref={ref} scale={scale}>
      <meshStandardMaterial></meshStandardMaterial>
    </Plane>
  );
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Static で Dynamic を押す

src/app/page.tsx
"use client";

import {
  BoxProps,
  Physics,
  PlaneProps,
  Triplet,
  useBox,
  usePlane,
} from "@react-three/cannon";
import {
  Box,
  OrbitControls,
  PerspectiveCamera,
  Plane,
} from "@react-three/drei";
import { Canvas, MeshProps, useFrame } from "@react-three/fiber";
import { FC, useRef } from "react";
import { Mesh, Vector3 } from "three";

export default function Home() {
  return (
    <main className="mx-auto container">
      <h1 className="text-4xl mt-4 mb-4">Robot arm physics</h1>
      <Canvas
        style={{
          width: "70%",
          height: "70vh",
        }}
      >
        <Physics>
          <OrbitControls></OrbitControls>
          <ambientLight intensity={0.2}></ambientLight>
          <pointLight position={[5, 10, 5]} intensity={0.6}></pointLight>
          <PerspectiveCamera
            makeDefault
            position={[5, 5, 5]}
          ></PerspectiveCamera>
          <MyStaticBox
            position={[1, 0.5, 1]}
            args={[1, 1, 1]}
            type="Static"
          ></MyStaticBox>
          <MyBox mass={1} position={[-1, 0.5, -1]} args={[1, 1, 1]}></MyBox>
          <MyPlane
            rotation={[-Math.PI / 2, 0, 0]}
            scale={[10, 10, 10]}
            position={[0, 0, 0]}
          ></MyPlane>
        </Physics>
      </Canvas>
    </main>
  );
}

type MyStaticBoxProps = Omit<BoxProps, "args" | "position" | "type"> & {
  args: Exclude<BoxProps["args"], undefined>;
  position: Exclude<BoxProps["position"], undefined>;
  type: "Static";
};

const MyStaticBox: FC<MyStaticBoxProps> = ({ args, position, ...rest }) => {
  const [ref, api] = useBox(
    () => ({
      args,
      position,
      ...rest,
    }),
    useRef<Mesh>(null)
  );

  useFrame(({ clock }) => {
    const t = clock.getElapsedTime();
    const [xx, yy, zz] = position;
    const length = Math.sqrt(xx * xx + zz * zz);
    const x = length * Math.sin(t);
    const y = yy;
    const z = length * Math.cos(t);
    api.position.set(x, y, z);
  });

  return (
    <Box ref={ref} scale={args}>
      <meshStandardMaterial></meshStandardMaterial>
    </Box>
  );
};

type MyBoxProps = BoxProps;

const MyBox: FC<MyBoxProps> = ({ args, ...rest }) => {
  const [ref] = useBox(
    () => ({
      args,
      ...rest,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Box ref={ref} scale={args}>
      <meshStandardMaterial></meshStandardMaterial>
    </Box>
  );
};

type MyPlaneProps = PlaneProps & Pick<MeshProps, "scale">;

const MyPlane: FC<MyPlaneProps> = ({ scale, ...rest }) => {
  const [ref] = usePlane(
    () => ({
      ...rest,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Plane ref={ref} scale={scale}>
      <meshStandardMaterial></meshStandardMaterial>
    </Plane>
  );
};


静止画ではわかりにくいが MyBox が MyStaticBox に押される

ちなみに MyBox と MyStaticBox の座標が重なっていると MyBox が吹っ飛んでいくのでシュール。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/1 (木) はここまで

2 時間学んだので累計で 8 時間になった。

React Three Fiber で表示するだけだったら group で自動計算してもらえたが Cannon を使う場合は自分で頑張って計算する必要があるかも知れない。

平行移動と回転だけとはいえなかなか大変そうだ。

あと 2 つの Static で挟めば把持(grasp)ってできるのだろうか。

なんか吹っ飛んでいきそうな予感がする。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/2 (金) はここから

今日は簡単なロボットを作って箱を A 地点から B 地点へ移動させるゲームを作り始めてみよう。

きっと今日では終わらないだろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

まずは物理シミュレーションなしで作る

src/app/page.tsx
"use client";

import { Box, Cylinder, OrbitControls, Plane } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";

export default function Home() {
  return (
    <main className="container mx-auto">
      <h1 className="text-4xl mt-4 mb-4">Robot arm game</h1>
      <Canvas
        style={{
          width: "70%",
          height: "70vh",
        }}
        camera={{
          position: [50, 50, 50],
        }}
      >
        <pointLight position={[100, 200, 100]} intensity={0.8}></pointLight>
        <ambientLight intensity={0.1}></ambientLight>
        <OrbitControls></OrbitControls>
        <group>
          <Cylinder args={[10, 10, 20, 12]} position={[0, 10, 0]}>
            <meshStandardMaterial></meshStandardMaterial>
          </Cylinder>
          <Box args={[20, 4, 10]} position={[15, 11, 0]}>
            <meshStandardMaterial></meshStandardMaterial>
          </Box>
        </group>
        <Plane args={[100, 100]} rotation={[-Math.PI / 2, 0, 0]}>
          <meshStandardMaterial color={"#888"}></meshStandardMaterial>
        </Plane>
      </Canvas>
    </main>
  );
}


この子をくるくる回してみようと思います

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

動かせるようにした

src/app/page.tsx
"use client";

import { Box, Cylinder, OrbitControls, Plane } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { ChangeEvent, useState } from "react";

export default function Home() {
  const [controller, setController] = useState({
    positionX: 0,
    positionZ: 0,
    axis0: 0,
  });

  const onChange = (event: ChangeEvent<HTMLInputElement>) =>
    setController({
      ...controller,
      [event.target.name]: parseInt(event.target.value, 10),
    });

  const rangeInput = (name: string, value: number) => (
    <div className="mb-2 flex items-center">
      <label htmlFor={name} className="mr-2">
        {name}
      </label>
      <input
        type="range"
        id={name}
        name={name}
        max={80}
        min={-80}
        value={value}
        onChange={onChange}
      />
    </div>
  );

  return (
    <main className="container mx-auto">
      <h1 className="text-4xl mt-4 mb-4">Robot arm game</h1>
      <form className="mb-4">
        {rangeInput("positionX", controller.positionX)}
        {rangeInput("positionZ", controller.positionZ)}
        {rangeInput("axis0", controller.axis0)}
      </form>
      <Canvas
        style={{
          width: "70%",
          height: "70vh",
        }}
        camera={{
          position: [50, 50, 50],
        }}
      >
        <pointLight position={[100, 200, 100]} intensity={0.8}></pointLight>
        <ambientLight intensity={0.1}></ambientLight>
        <OrbitControls></OrbitControls>
        <group
          position={[controller.positionX, 0, controller.positionZ]}
          rotation={[0, (controller.axis0 * Math.PI) / 180, 0]}
        >
          <Cylinder args={[10, 10, 20, 12]} position={[0, 10, 0]}>
            <meshStandardMaterial></meshStandardMaterial>
          </Cylinder>
          <Box args={[20, 4, 10]} position={[15, 11, 0]}>
            <meshStandardMaterial></meshStandardMaterial>
          </Box>
        </group>
        <Plane args={[200, 200]} rotation={[-Math.PI / 2, 0, 0]}>
          <meshStandardMaterial color={"#888"}></meshStandardMaterial>
        </Plane>
      </Canvas>
    </main>
  );
}


くるくる回るようになりました

ここまでで 1 時間経ってしまった。

午後からもう 1 時間再開しよう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

試行錯誤の末できた

src/app/page.tsx
"use client";

import { Box, Cylinder, OrbitControls, Plane } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";
import { ChangeEvent, useState } from "react";
import { Matrix4 } from "three";

export default function Home() {
  const [controller, setController] = useState({
    positionX: 0,
    positionZ: 0,
    axis0: 0,
  });

  const onChange = (event: ChangeEvent<HTMLInputElement>) =>
    setController({
      ...controller,
      [event.target.name]: parseInt(event.target.value, 10),
    });

  const rangeInput = (name: string, value: number, max: number) => (
    <div className="mb-2 flex items-center">
      <label htmlFor={name} className="mr-2">
        {name}
      </label>
      <input
        type="range"
        id={name}
        name={name}
        max={max}
        min={-max}
        value={value}
        onChange={onChange}
      />
    </div>
  );

  const matrixController = new Matrix4()
    .makeTranslation(controller.positionX, 0, controller.positionZ)
    .multiply(new Matrix4().makeRotationY((controller.axis0 * Math.PI) / 180));

  const matrixCylinder = new Matrix4().makeTranslation(0, 10, 0);
  const matrixBox = new Matrix4().makeTranslation(15, 11, 0);

  return (
    <main className="container mx-auto">
      <h1 className="text-4xl mt-4 mb-4">Robot arm game</h1>
      <form className="mb-4">
        {rangeInput("positionX", controller.positionX, 80)}
        {rangeInput("positionZ", controller.positionZ, 80)}
        {rangeInput("axis0", controller.axis0, 180)}
      </form>
      <Canvas
        style={{
          width: "70%",
          height: "70vh",
        }}
        camera={{
          position: [50, 50, 50],
        }}
      >
        <pointLight position={[100, 200, 100]} intensity={0.8}></pointLight>
        <ambientLight intensity={0.1}></ambientLight>
        <OrbitControls></OrbitControls>
        <Cylinder
          args={[10, 10, 20, 12]}
          matrixAutoUpdate={false}
          matrix={matrixController.clone().multiply(matrixCylinder)}
        >
          <meshStandardMaterial></meshStandardMaterial>
        </Cylinder>
        <Box
          args={[20, 4, 10]}
          matrixAutoUpdate={false}
          matrix={matrixController.clone().multiply(matrixBox)}
        >
          <meshStandardMaterial></meshStandardMaterial>
        </Box>
        <Plane args={[200, 200]} rotation={[-Math.PI / 2, 0, 0]}>
          <meshStandardMaterial color={"#888"}></meshStandardMaterial>
        </Plane>
      </Canvas>
    </main>
  );
}


group のありがたみがよくわかりました

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/2 (金) はここまで

2 時間学んだので累計で 10 時間になった。

次回はどうにかして位置と回転の情報を取り出して Cannon の側と同期すればやりたいことができる気がする。

これができたら次は把持を試してみたい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Position と Quaternion を使う

Matrix4 で指定していた部分を Position と Quaternion に切り替える。

src/app/page.tsx(抜粋)
  const matrixController = new Matrix4()
    .makeTranslation(controller.positionX, 0, controller.positionZ)
    .multiply(new Matrix4().makeRotationY((controller.axis0 * Math.PI) / 180));

  const matrixLocalCylinder = new Matrix4().makeTranslation(0, 10, 0);
  const matrixLocalBox = new Matrix4().makeTranslation(15, 11, 0);

  const matrixCylinder = matrixController.clone().multiply(matrixLocalCylinder);
  const matrixBox = matrixController.clone().multiply(matrixLocalBox);

  const positionCylinder = new Vector3().setFromMatrixPosition(matrixCylinder);
  const positionBox = new Vector3().setFromMatrixPosition(matrixBox);

  const quaternionCylinder = new Quaternion().setFromRotationMatrix(
    matrixCylinder
  );
  const quaternionBox = new Quaternion().setFromRotationMatrix(matrixBox);


表示されるものが変わらなければ OK

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

decompose() を使うこともできる

src/app/page.tsx
  const matrixController = new Matrix4()
    .makeTranslation(controller.positionX, 0, controller.positionZ)
    .multiply(new Matrix4().makeRotationY((controller.axis0 * Math.PI) / 180));

  const matrixLocalCylinder = new Matrix4().makeTranslation(0, 10, 0);
  const matrixLocalBox = new Matrix4().makeTranslation(15, 11, 0);

  const matrixCylinder = matrixController.clone().multiply(matrixLocalCylinder);
  const matrixBox = matrixController.clone().multiply(matrixLocalBox);

  const positionCylinder = new Vector3();
  const positionBox = new Vector3();

  const quaternionCylinder = new Quaternion();
  const quaternionBox = new Quaternion();

  matrixCylinder.decompose(positionCylinder, quaternionCylinder, new Vector3());
  matrixBox.decompose(positionBox, quaternionBox, new Vector3());
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

物理演算付きシリンダーを作る

src/app/page.tsx(抜粋)
type MyCylinderProps = Omit<MeshProps, "args" | "position" | "quaternion"> & {
  args: CylinderBufferGeometryProps["args"];
  position: Vector3;
  quaternion: Quaternion;
};

const MyCylinder: FC<MyCylinderProps> = ({
  args,
  position,
  quaternion,
  children,
  ...rest
}) => {
  const [ref, api] = useCylinder(
    () => ({
      args,
      position: [position.x, position.y, position.z],
      quaternion: [quaternion.x, quaternion.y, quaternion.z, quaternion.w],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  useEffect(() => {
    api.position.copy(position);
  }, [api.position, position]);

  useEffect(() => {
    api.quaternion.copy(quaternion);
  }, [api.quaternion, quaternion]);

  return (
    <Cylinder ref={ref} args={args} {...rest}>
      {children}
    </Cylinder>
  );
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

物理演算付きの動く箱を作る

src/app/page.tsx(抜粋)
type MyDynamicBoxProps = Omit<MeshProps, "args" | "position"> & {
  args: Triplet;
  position: Triplet;
  mass: number;
};

const MyDynamicBox: FC<MyDynamicBoxProps> = ({
  args,
  position,
  mass,
  children,
  ...rest
}) => {
  const [ref, api] = useBox(
    () => ({
      args,
      position,
      mass,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Box ref={ref} args={args}>
      {children}
    </Box>
  );
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

物理演算付きの地面を作る

src/app/page.tsx(抜粋)
type MyPlaneProps = Omit<MeshProps, "args" | "rotation"> & {
  args: PlaneGeometryProps["args"];
  rotation: Triplet;
};

const MyPlane: FC<MyPlaneProps> = ({ args, rotation, children }) => {
  const [ref] = usePlane(
    () => ({
      rotation,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Plane ref={ref} args={args}>
      {children}
    </Plane>
  );
};
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

なんやかんやありまして

ロボットを操作して箱を動かせるようになりました。

src/app/page.tsx(全部)
"use client";

import {
  CylinderProps,
  Physics,
  Triplet,
  useBox,
  useCylinder,
  usePlane,
} from "@react-three/cannon";
import { Box, Cylinder, OrbitControls, Plane } from "@react-three/drei";
import {
  BoxBufferGeometryProps,
  BoxGeometryProps,
  Canvas,
  CylinderBufferGeometryProps,
  CylinderGeometryProps,
  MeshProps,
  PlaneGeometryProps,
  useFrame,
} from "@react-three/fiber";
import { ChangeEvent, FC, useEffect, useRef, useState } from "react";
import { Euler, Matrix4, Mesh, Quaternion, Vector3 } from "three";

export default function Home() {
  const [controller, setController] = useState({
    positionX: 0,
    positionZ: 0,
    axis0: 0,
  });

  const onChange = (event: ChangeEvent<HTMLInputElement>) =>
    setController({
      ...controller,
      [event.target.name]: parseInt(event.target.value, 10),
    });

  const rangeInput = (name: string, value: number, max: number) => (
    <div className="mb-2 flex items-center">
      <label htmlFor={name} className="mr-2">
        {name}
      </label>
      <input
        type="range"
        id={name}
        name={name}
        max={max}
        min={-max}
        value={value}
        onChange={onChange}
      />
    </div>
  );

  const matrixController = new Matrix4()
    .makeTranslation(controller.positionX, 0, controller.positionZ)
    .multiply(new Matrix4().makeRotationY((controller.axis0 * Math.PI) / 180));

  const matrixLocalCylinder = new Matrix4().makeTranslation(0, 10, 0);
  const matrixLocalBox = new Matrix4().makeTranslation(15, 11, 0);

  const matrixCylinder = matrixController.clone().multiply(matrixLocalCylinder);
  const matrixBox = matrixController.clone().multiply(matrixLocalBox);

  const positionCylinder = new Vector3();
  const positionBox = new Vector3();

  const quaternionCylinder = new Quaternion();
  const quaternionBox = new Quaternion();

  matrixCylinder.decompose(positionCylinder, quaternionCylinder, new Vector3());
  matrixBox.decompose(positionBox, quaternionBox, new Vector3());

  return (
    <main className="container mx-auto">
      <h1 className="text-4xl mt-4 mb-4">Robot arm game</h1>
      <form className="mb-4">
        {rangeInput("positionX", controller.positionX, 80)}
        {rangeInput("positionZ", controller.positionZ, 80)}
        {rangeInput("axis0", controller.axis0, 180)}
      </form>
      <Canvas
        style={{
          width: "70%",
          height: "70vh",
        }}
        camera={{
          position: [50, 50, 50],
        }}
      >
        <Physics gravity={[0, -98.1, 0]}>
          <pointLight position={[100, 200, 100]} intensity={0.8}></pointLight>
          <ambientLight intensity={0.1}></ambientLight>
          <OrbitControls></OrbitControls>
          <MyPlane args={[200, 200]} rotation={[-Math.PI / 2, 0, 0]}>
            <meshStandardMaterial color={"#888"}></meshStandardMaterial>
          </MyPlane>
          <MyCylinder
            args={[10, 10, 20, 12]}
            position={positionCylinder}
            rotation={new Euler().setFromQuaternion(quaternionCylinder)}
          >
            <meshStandardMaterial></meshStandardMaterial>
          </MyCylinder>
          <MyBox
            args={[20, 4, 10]}
            position={positionBox}
            rotation={new Euler().setFromQuaternion(quaternionBox)}
          >
            <meshStandardMaterial></meshStandardMaterial>
          </MyBox>
          <MyDynamicBox args={[30, 30, 30]} position={[0, 50, 0]} mass={1}>
            <meshStandardMaterial></meshStandardMaterial>
          </MyDynamicBox>
        </Physics>
      </Canvas>
    </main>
  );
}

type MyDynamicBoxProps = Omit<MeshProps, "args" | "position"> & {
  args: Triplet;
  position: Triplet;
  mass: number;
};

const MyDynamicBox: FC<MyDynamicBoxProps> = ({
  args,
  position,
  mass,
  children,
  ...rest
}) => {
  const [ref, api] = useBox(
    () => ({
      args,
      position,
      mass,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Box ref={ref} args={args}>
      {children}
    </Box>
  );
};

type MyPlaneProps = Omit<MeshProps, "args" | "rotation"> & {
  args: PlaneGeometryProps["args"];
  rotation: Triplet;
};

const MyPlane: FC<MyPlaneProps> = ({ args, rotation, children }) => {
  const [ref] = usePlane(
    () => ({
      rotation,
    }),
    useRef<Mesh>(null)
  );

  return (
    <Plane ref={ref} args={args}>
      {children}
    </Plane>
  );
};

type MyCylinderProps = Omit<MeshProps, "args" | "position" | "rotation"> & {
  args: CylinderBufferGeometryProps["args"];
  position: Vector3;
  rotation: Euler;
};

const MyCylinder: FC<MyCylinderProps> = ({
  args,
  position,
  rotation,
  children,
  ...rest
}) => {
  const [ref, api] = useCylinder(
    () => ({
      args,
      position: [position.x, position.y, position.z],
      rotation: [rotation.x, rotation.y, rotation.z],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  useEffect(() => {
    api.position.copy(position);
  }, [api.position, position]);

  useEffect(() => {
    api.rotation.copy(rotation);
  }, [api.rotation, rotation]);

  return (
    <Cylinder ref={ref} args={args} {...rest}>
      {children}
    </Cylinder>
  );
};

type MyBoxProps = Omit<MeshProps, "args" | "position" | "rotation"> & {
  args: Triplet;
  position: Vector3;
  rotation: Euler;
};

const MyBox: FC<MyBoxProps> = ({
  args,
  position,
  rotation,
  children,
  ...rest
}) => {
  const [ref, api] = useBox(
    () => ({
      args,
      position: [position.x, position.y, position.z],
      rotation: [rotation.x, rotation.y, rotation.z],
      type: "Static",
    }),
    useRef<Mesh>(null)
  );

  useEffect(() => {
    api.position.copy(position);
  }, [api.position, position]);

  useEffect(() => {
    api.rotation.copy(rotation);
  }, [api.rotation, rotation]);

  return (
    <Box ref={ref} args={args} {...rest}>
      {children}
    </Box>
  );
};


動画の方がわかりやすいが動画を撮影する気力がなくなってしまった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/14 (水) はここから

9 日ぶりの更新となってしまった。

前回は「ここまで」投稿を忘れたが確か 2 時間稼働だったので累計で今は 12 時間になっているはず。

とりあえず Static を使って物理空間に関与できるようになったものの、もう少し良いやり方があるのではないかと思っている。

今日は公式 Example の中からロボットに関連する例をピックアップして写経していこうと思う。


https://cannon.pmnd.rs/#/demo/HingeMotor

ソースコードは下記の通り、300 行程度とちょうど良い。

https://github.com/pmndrs/use-cannon/blob/master/packages/react-three-cannon-examples/src/demos/demo-HingeMotor.tsx

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ワークスペースの作成

コマンド
# cd ~/workspace
npx create-next-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*" \
  --use-npm \
  hinge-motor
npm install three @types/three @react-three/fiber @react-three/drei @react-three/cannon
cd hinge-motor
npm run dev
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

まっさらにする

src/app/page.tsx
"use client";

export default function Home() {
  return <main></main>;
}
src/app/layout.tsx
import "./globals.css";

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
src/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

はじめの一歩

src/app/page.tsx
"use client";

import { OrbitControls } from "@react-three/drei";
import { Canvas } from "@react-three/fiber";

export default function Home() {
  return (
    <main className="container mx-auto">
      <h1 className="text-4xl mt-4 mb-4">Hinge Motor</h1>
      <Canvas
        shadows
        gl={{ alpha: false }}
        style={{
          width: "70%",
          height: "70vh",
        }}
      >
        <OrbitControls></OrbitControls>
        <Scene></Scene>
      </Canvas>
    </main>
  );
}

function Scene() {
  return <></>;
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

カメラをロボットに向ける

src/app/page.tsx(抜粋)
const v = new Vector3();

function Scene() {
  const cameraRef = useRef<Cam>(null);
  const robotRef = useRef<Mesh>(null);

  // カメラがロボットの方を向くようにしています。
  useFrame(() => {
    if (!cameraRef.current || !robotRef.current) {
      return;
    }

    robotRef.current.getWorldPosition(v);
    cameraRef.current.lookAt(v);
  });

  return <></>;
}

こうすれば良いのか、やはり写経は勉強になる。

ところで v にも useRef 使っても良い気がするがなぜグローバルにしているんだろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

カメラやライトの準備

src/app/page.tsx(抜粋)
function Scene() {
  const cameraRef = useRef<Cam>(null);
  const robotRef = useRef<Mesh>(null);

  // カメラがロボットの方を向くようにしています。
  useFrame(() => {
    if (!cameraRef.current || !robotRef.current) {
      return;
    }

    robotRef.current.getWorldPosition(v);
    cameraRef.current.lookAt(v);
  });

  return (
    <Suspense fallback={null}>
      <PerspectiveCamera
        ref={cameraRef}
        makeDefault
        position={[-40, 10, 20]}
      ></PerspectiveCamera>

      <hemisphereLight intensity={0.35}></hemisphereLight>
      <spotLight
        position={[20, 30, 10]}
        angle={Math.PI / 5}
        penumbra={1}
        intensity={1}
        distance={180}
        castShadow
        shadow-mapSize-width={256}
        shadow-mapSize-height={256}
      ></spotLight>
      <color attach="background" args={["#f6d186"]}></color>

      <Physics iterations={80} gravity={[0, -40, 0]}>
        
      </Physics>
    </Suspense>
  );
}


背景に色がついた

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

地面ができた

src/app/page.tsx(抜粋)
const GROUP_GROUND = 2 ** 0;
const GROUP_BODY = 2 ** 1;

type OurPlaneProps = Pick<PlaneBufferGeometryProps, "args"> &
  Pick<PlaneProps, "position" | "rotation">;

function Plane({ args, ...props }: OurPlaneProps) {
  const [ref] = usePlane(
    () => ({
      collisionFilterGroup: GROUP_GROUND,
      type: "Static",
      ...props,
    }),
    useRef<Group>(null)
  );

  return (
    <group ref={ref}>
      <mesh>
        <planeBufferGeometry args={args}></planeBufferGeometry>
        <meshBasicMaterial color="#ffb385"></meshBasicMaterial>
      </mesh>
      <mesh receiveShadow>
        <planeBufferGeometry args={args}></planeBufferGeometry>
        <shadowMaterial color="lightsalmon"></shadowMaterial>
      </mesh>
    </group>
  );
}
src/app/page.tsx(抜粋)
      <Physics iterations={80} gravity={[0, -40, 0]}>
        <Plane
          args={[120, 120]}
          position={[-20, -5, 0]}
          rotation={[-Math.PI / 2, 0, 0]}
        ></Plane>
      </Physics>


薄い茶色の地面ができた

collisionFilterGroup とは何なのだろう?下記のデモが参考になりそう。

https://schteppe.github.io/cannon.js/demos/collisionFilter.html

ここまでで約 1 時間経過した。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ConstraintPart の追加

src/app/page.tsx(抜粋)

type BoxShapeProps = Pick<
  MeshStandardMaterialProps,
  "color" | "opacity" | "transparent"
> &
  Pick<BoxProps, "args">;

const BoxShape = forwardRef<Mesh, PropsWithChildren<BoxShapeProps>>(
  (
    {
      args = [1, 1, 1],
      children,
      color = "white",
      opacity = 1,
      transparent = false,
      ...props
    },
    ref
  ) => (
    <mesh receiveShadow castShadow ref={ref} {...props}>
      <boxBufferGeometry args={args}></boxBufferGeometry>
      <meshStandardMaterial
        color={color}
        transparent={transparent}
        opacity={opacity}
      ></meshStandardMaterial>
    </mesh>
  )
);

const ref = createRef<Object3D>();
const context = createContext<
  [bodyRef: RefObject<Object3D>, props: BoxShapeProps]
>([ref, {}]);

function normalizeSize([px = 0, py = 0, pz = 0]): (scale: Triplet) => Triplet {
  return ([ox = 1, oy = 1, oz = 1]) => [px * ox, py * oy, pz * oz];
}

type ConstraintProps = {
  config?: HingeConstraintOpts;
  enableMotor: boolean;
  motorSpeed?: number;
  parentPivot?: Triplet;
  pivot?: Triplet;
} & BoxProps &
  BoxShapeProps;

const ConstraintPart = forwardRef<Mesh, PropsWithChildren<ConstraintProps>>(
  (
    {
      config = {},
      enableMotor,
      motorSpeed = 7,
      color,
      children,
      pivot = [0, 0, 0],
      parentPivot = [0, 0, 0],
      ...props
    },
    ref
  ) => {
    const parent = useContext(context);
    const normParentPivot =
      parent && parent[1].args
        ? normalizeSize(parent[1].args)
        : () => undefined;

    const normPivot = props.args ? normalizeSize(props.args) : () => undefined;

    const [bodyRef] = useBox(
      () => ({
        collisionFilterGroup: GROUP_BODY,
        collisionFilterMask: GROUP_GROUND,
        linearDamping: 0.4,
        mass: 1,
        ...props,
      }),
      ref
    );

    const [, , hingeApi] = useHingeConstraint(bodyRef, parent[0], {
      axisA: [0, 0, 1],
      axisB: [0, 0, 1],
      collideConnected: false,
      pivotA: normPivot(pivot),
      pivotB: normParentPivot(parentPivot),
      ...config,
    });

    useEffect(() => {
      if (enableMotor) {
        hingeApi.enableMotor();
      } else {
        hingeApi.disableMotor();
      }
    }, [enableMotor]);

    useEffect(() => {
      hingeApi.setMotorSpeed(motorSpeed);
    }, [motorSpeed]);

    return (
      <context.Provider value={[bodyRef, props]}>
        <BoxShape ref={bodyRef} {...props} color={color}></BoxShape>
        {children}
      </context.Provider>
    );
  }
);

何をやっているかはよくわからない。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

障害物の追加

src/app/page.tsx(抜粋)
function Obstacles() {
  return (
    <>
      <ConstraintPart
        collisionFilterGroup={GROUP_GROUND}
        collisionFilterMask={GROUP_BODY | GROUP_GROUND}
        mass={4}
        args={[-30, -0.4, 30]}
        position={[-45, -4, 0]}
        rotation={[0, Math.PI / -4, 0]}
        color={"#ffb385"}
      ></ConstraintPart>
      <ConstraintPart
        collisionFilterGroup={GROUP_GROUND}
        collisionFilterMask={GROUP_BODY | GROUP_GROUND}
        mass={4}
        args={[-15, -0.5, 15]}
        position={[-50, -2, 0]}
        rotation={[0, Math.PI / -1.25, 0]}
        color={"#dc9c76"}
      ></ConstraintPart>
      <ConstraintPart
        collisionFilterGroup={GROUP_GROUND}
        collisionFilterMask={GROUP_BODY | GROUP_GROUND}
        mass={4}
        args={[-10, -0.5, 10]}
        position={[-45, 0, -5]}
        rotation={[0, Math.PI / 3, 0]}
        color={"#c58e6e"}
      ></ConstraintPart>
    </>
  );
}
src/app/page.tsx(抜粋)
      <Physics iterations={80} gravity={[0, -40, 0]}>
        <Plane
          args={[120, 120]}
          position={[-20, -5, 0]}
          rotation={[-Math.PI / 2, 0, 0]}
        ></Plane>
        <Obstacles></Obstacles>
      </Physics>


3 枚の板のようなものが追加された

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

衝突について考える

衝突は次の条件が満たされた場合に発生するようだ。

(bodyA.collisionFilterGroup & bodyB.collisionFilterMask) && (bodyB.collisionFilterGroup & bodyA.collisionFilterMask)

地面の collisionFilterGroup は GROUP_GROUND、collisionFilterMask は未設定だと -1 = 0x1111...1111 になるようだ。

https://github.com/pmndrs/cannon-es/blob/master/src/objects/Body.ts

一方、障害物の collisionFilterGroup は GROUP_GROUND、collisionFilterMask は GROUP_BODY | GROUP_GROUND に設定されている。

したがって障害物は地面と衝突判定が行われる。

また、障害物同士でも当たり判定が行われることになる。

collisionFilterGroup を所属するグループ、collisionFilterMask を衝突対象のグループと考えるとわかりやすい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/14 (水) はここまで

2 時間学んだので累計で 14 時間となった。

あと 2 時間くらいで写経を終わらせたいところだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ロボットの追加

src/app/page.tsx(抜粋)
type LegsProps = {
  bodyDepth?: number;
  delay?: number;
} & Pick<ConstraintProps, "motorSpeed">;

const Legs = forwardRef<Mesh, LegsProps>(
  ({ bodyDepth = 0, delay = 0, motorSpeed = 7 }, bodyRef) => {
    const horizontalRef = useRef<Mesh>(null);
    const frontLegRef = useRef<Mesh>(null);
    const frontUpperLegRef = useRef<Mesh>(null);
    const backLegRef = useRef<Mesh>(null);
    const partDepth = 0.3;
    const bodyWidth = 10;
    const bodyHeight = 2;
    const legLength = 6;
    const size3 = normalizeSize([1, 3, partDepth]);
    const size5 = normalizeSize([1, 5, partDepth]);
    const size10 = normalizeSize([1, 10, partDepth]);

    useHingeConstraint(frontUpperLegRef, frontLegRef, {
      axisA: [0, 0, 1],
      axisB: [0, 0, 1],
      collideConnected: false,
      pivotA: size3([0, 0.5, 0.5]),
      pivotB: size5([0, 0.5, -0.5]),
    });

    useHingeConstraint(backLegRef, horizontalRef, {
      axisA: [0, 0, 1],
      axisB: [0, 0, 1],
      collideConnected: false,
      pivotA: size3([0, 0.5, 0.5]),
      pivotB: size5([0, 0.5, -0.5]),
    });

    const [isWalking, setIsWalking] = useState(false);

    useEffect(() => {
      const t = setTimeout(() => setIsWalking(true), delay);

      return () => clearTimeout(t);
    }, []);

    return (
      <group>
        <ConstraintPart
          ref={bodyRef}
          mass={1}
          args={[
            bodyHeight,
            bodyWidth,
            bodyDepth ? bodyDepth + partDepth * 3 : 0,
          ]}
          rotation={[0, 0, Math.PI / 2]}
          position={[0, 0, bodyDepth]}
          transparent={!bodyDepth}
          opacity={Number(!!bodyDepth)}
        >
          <ConstraintPart
            ref={frontUpperLegRef}
            args={[1, 3, partDepth]}
            position={[-2, 0.5, bodyDepth]}
            rotation={[0, 0, Math.PI / 3]}
            pivot={[0, -0.5, -0.5]}
            parentPivot={[0, 0.2, 0.5]}
            color={"#85ffb3"}
          ></ConstraintPart>
          <ConstraintPart
            enableMotor={isWalking}
            motorSpeed={motorSpeed}
            args={[0.5, 1, partDepth]}
            position={[bodyWidth * -0.5, -1.5 / 2, bodyDepth]}
            parentPivot={[0, 0.5, 0.5]}
            pivot={[0, 0.5, -0.5]}
            color={"black"}
          >
            <ConstraintPart
              ref={frontLegRef}
              args={[1, legLength, partDepth]}
              position={[bodyWidth * -0.5, -1, bodyDepth]}
              rotation={[0, 0, Math.PI / -6]}
              parentPivot={[0, -0.5, 0.5]}
              pivot={[0, 0, -0.5]}
              color={"#85b3ff"}
            >
              <ConstraintPart
                ref={horizontalRef}
                parentPivot={[0, 0, 0.5]}
                pivot={[0, -0.5, -0.5]}
                args={[1, bodyWidth, partDepth]}
                position={[0, 0, bodyDepth]}
                color={"#ff85b3"}
                rotation={[0, 0, Math.PI / -2.5]}
              ></ConstraintPart>
            </ConstraintPart>
          </ConstraintPart>

          <ConstraintPart
            ref={backLegRef}
            args={[1, legLength, partDepth]}
            pivot={[0, 0, -1]}
            parentPivot={[-0.0, -0.5, 0.5]}
            position={[bodyWidth * 0.5, 0, bodyDepth]}
            rotation={[0, 0, Math.PI / 4]}
            color={"#85b3ff"}
          ></ConstraintPart>
        </ConstraintPart>
      </group>
    );
  }
);

const Robot = forwardRef<Mesh>((_, legsLeftRef) => {
  const [motorSpeed, setMotorSpeed] = useState(7);
  const legsRightRef = useRef<Mesh>(null);

  useLockConstraint(legsRightRef, legsLeftRef, {});

  return (
    <group
      onPointerDown={() => setMotorSpeed(2)}
      onPointerUp={() => setMotorSpeed(7)}
    >
      <Legs
        ref={legsLeftRef}
        delay={1000}
        bodyDepth={3}
        motorSpeed={motorSpeed}
      ></Legs>
      <Legs
        ref={legsRightRef}
        delay={1000}
        bodyDepth={3}
        motorSpeed={motorSpeed}
      ></Legs>
    </group>
  );
});
src/app/page.tsx(抜粋)
      <Physics iterations={80} gravity={[0, -40, 0]}>
        <Plane
          args={[120, 120]}
          position={[-20, -5, 0]}
          rotation={[-Math.PI / 2, 0, 0]}
        ></Plane>
        <Obstacles></Obstacles>
        <Robot ref={robotRef}></Robot>
      </Physics>


レッグが片方しかない

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

レッグを両方にする

設定ミスだった。

src/app/page.tsx
const Robot = forwardRef<Mesh>((_, legsLeftRef) => {
  const [motorSpeed, setMotorSpeed] = useState(7);
  const legsRightRef = useRef<Mesh>(null);

  useLockConstraint(legsRightRef, legsLeftRef, {});

  return (
    <group
      onPointerDown={() => setMotorSpeed(2)}
      onPointerUp={() => setMotorSpeed(7)}
    >
      <Legs
        ref={legsLeftRef}
        delay={1000}
        bodyDepth={3}
        motorSpeed={motorSpeed}
      ></Legs>
      <Legs ref={legsRightRef} motorSpeed={motorSpeed}></Legs>
    </group>
  );
});


何やら動きがおかしい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ヒンジ制約の修正

差分
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 3b14e61..1334958 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -315,8 +315,8 @@ const Legs = forwardRef<Mesh, LegsProps>(
       axisA: [0, 0, 1],
       axisB: [0, 0, 1],
       collideConnected: false,
-      pivotA: size3([0, 0.5, 0.5]),
-      pivotB: size5([0, 0.5, -0.5]),
+      pivotA: size5([0, 0.5, 0.5]),
+      pivotB: size10([0, 0.5, -0.5]),
     });
 
     const [isWalking, setIsWalking] = useState(false);


ようやく完成した

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ここまで 1 時間

合計では 15 時間となった。

あと 1 時間くらいかけてコードから新しいテクニックを学び取ろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/21 (水) はここから

前から 1 週間近く経過してしまった。

前回予定した通り、あと 1 時間かけてコードから新しいテクニックを学び取ろう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Legs

片方は中央の箱付きでもう片方は中央の箱なしになっている。

どういう仕組みになっているんだろうと気になったので調べてみたところ、片方の bodyDepth が 0 になっていることがわかった。

bodyDepth がゼロなのに物体として扱われるのかが気になったが、ゼロの場合はものすごく薄い板のような扱いになるようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

今更だけど

奥の方の黒い部品が物理法則を無視したところに付いてることに気づいた。

自分の間違いかと思ったが公式もそうだったので意図的なものだろう。

確かに反転ささせるのとか面倒そう。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

コメントを付けてみた

src/app/page.tsx
const Legs = forwardRef<Mesh, LegsProps>(
  ({ bodyDepth = 0, delay = 0, motorSpeed = 7 }, bodyRef) => {
    const horizontalRef = useRef<Mesh>(null);
    const frontLegRef = useRef<Mesh>(null);
    const frontUpperLegRef = useRef<Mesh>(null);
    const backLegRef = useRef<Mesh>(null);
    const partDepth = 0.3;
    const bodyWidth = 10;
    const bodyHeight = 2;
    const legLength = 6;
    const size3 = normalizeSize([1, 3, partDepth]);
    const size5 = normalizeSize([1, 5, partDepth]);
    const size10 = normalizeSize([1, 10, partDepth]);

    // ボディに接続された緑色の板を青色の前足を接続する。
    useHingeConstraint(frontUpperLegRef, frontLegRef, {
      axisA: [0, 0, 1],
      axisB: [0, 0, 1],
      collideConnected: false,
      pivotA: size3([0, 0.5, 0.3]),
      pivotB: size5([0, 0.5, -0.5]),
    });

    // 青色の後ろ足と水平のピンク色の板を接続する。
    useHingeConstraint(backLegRef, horizontalRef, {
      axisA: [0, 0, 1],
      axisB: [0, 0, 1],
      collideConnected: false,
      pivotA: size5([0, 0.5, 0.5]),
      pivotB: size10([0, 0.5, -0.5]),
    });

    const [isWalking, setIsWalking] = useState(false);

    useEffect(() => {
      const t = setTimeout(() => setIsWalking(true), delay);

      return () => clearTimeout(t);
    }, []);

    return (
      <group>
        {/* 中央の白い箱 */}
        <ConstraintPart
          ref={bodyRef}
          mass={1}
          args={[
            bodyHeight,
            bodyWidth,
            bodyDepth ? bodyDepth + partDepth * 3 : 0,
          ]}
          rotation={[0, 0, Math.PI / 2]}
          position={[0, 0, bodyDepth]}
          transparent={!bodyDepth}
          opacity={Number(!!bodyDepth)}
        >
          {/* 前足とボディに固定された緑色の板 */}
          <ConstraintPart
            ref={frontUpperLegRef}
            args={[1, 3, partDepth]}
            position={[-2, 0.5, bodyDepth]}
            rotation={[0, 0, Math.PI / 3]}
            pivot={[0, -0.5, -0.5]}
            parentPivot={[0, 0.2, 0.5]}
            color={"#85ffb3"}
          ></ConstraintPart>
          {/* 回転している黒色の板 */}
          <ConstraintPart
            enableMotor={isWalking}
            motorSpeed={motorSpeed}
            args={[0.5, 1, partDepth]}
            position={[bodyWidth * -0.5, -1.5 / 2, bodyDepth]}
            parentPivot={[0, 0.5, 0.5]}
            pivot={[0, 0.5, -0.5]}
            color={"black"}
          >
            {/* 青色の前足 */}
            <ConstraintPart
              ref={frontLegRef}
              args={[1, legLength, partDepth]}
              position={[bodyWidth * -0.5, -1, bodyDepth]}
              rotation={[0, 0, Math.PI / -6]}
              parentPivot={[0, -0.5, 0.5]}
              pivot={[0, 0, -0.5]}
              color={"#85b3ff"}
            >
              {/* 前足と後ろ足をつなぐ水平のピンク色の板 */}
              <ConstraintPart
                ref={horizontalRef}
                parentPivot={[0, 0, 0.5]}
                pivot={[0, -0.5, -0.5]}
                args={[1, bodyWidth, partDepth]}
                position={[0, 0, bodyDepth]}
                color={"#ff85b3"}
                rotation={[0, 0, Math.PI / -2.5]}
              ></ConstraintPart>
            </ConstraintPart>
          </ConstraintPart>
          <ConstraintPart
            ref={backLegRef}
            args={[1, legLength, partDepth]}
            pivot={[0, 0, -1]}
            parentPivot={[-0.0, -0.5, 0.5]}
            position={[bodyWidth * 0.5, 0, bodyDepth]}
            rotation={[0, 0, Math.PI / 4]}
            color={"#85b3ff"}
          ></ConstraintPart>
        </ConstraintPart>
      </group>
    );
  }
);
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

6/21 (水) はここまで

合計 16 時間、この辺りでクローズしよう。

ヒンジ制約は面白いが回転角の指定はできなさそうなので最初にやったように Static を使う方向でまずはやってみるか。

学びが多く充実した時間だった。

このスクラップは2023/06/21にクローズされました