
前回はスポットライトの実装について解説したので、今回はサイコロをクリックした際に吹っ飛ばす機能を実装していきます。
目次
使用技術
今回から主要パッケージとバージョンをちゃんと明記します(したい)。
パッケージ名 | バージョン |
---|---|
@react-three/fiber | 8.13.0 |
@react-three/drei | 9.66.1 |
@react-three/cannon | 6.5.2 |
zustand | 4.3.7 |
実装
トライアンドエラーで実装していきます。
─=≡Σ((( つ•̀ω•́)つ
下準備(物理場)
前回の時に床作りの説明をしていなかったので、床から作り始めます。どうしてライトの実装から始めてしまったんや。。。
@react-three/cannonでは、まずは物理演算を適用させる世界を宣言しなくてはなりません。
export default function MyGL() {
const mode = useMode();
return (
<Suspense fallback={null}>
<Canvas
shadows
gl={{ alpha: false }}
camera={{ position: [-5, 10, 5], fov: 50 }}
>
<Environment preset="sunset" />
<Lights />
<Physics broadphase="SAP">
<Debug color="red" scale={1.1}>
{/* ここにサイコロや床などの物理演算を適用させたコンポーネントを配置する。 */}
</Debug>
</Physics>
<OrbitControls makeDefault />
<Stats />
</Canvas>
</Suspense>
);
}
<Physics></Physics>
の内側に物理演算を適用させたサイコロなどを配置します。プロパティbroadphase
では衝突検出の前段階として働くアルゴリズムを指定できます。今回は「SAP」と呼ばれるアルゴリズムを指定しています。詳しくはcannon-es のドキュメントを確認してみてください。
また、デバッグの際はさらに<Debug></Debug>
で囲むと内にあるオブジェクトにワイヤーフレームが追加されるので、認識しやすくなります。
下準備(床)
物理場ができたので床を設置します。
const Plane = (props: PlaneProps) => {
const [ref] = usePlane(
() => ({
rotation: [-Math.PI / 2, 0, 0],
...props,
}),
useRef<Mesh>(null)
);
return (
<group>
<mesh ref={ref} receiveShadow>
<planeGeometry args={[100, 100]} />
<shadowMaterial />
</mesh>
</group>
);
};
export default Plane;
@react-three/cannonのusePlane
をプレートに適用し、物理を適用します。現段階では特に変なことはしていません。物理場である<Physics></Physics>
の中で宣言することを忘れないでください。
下準備(サイコロ)
サイコロを召喚します。
useGLTF.preload("/assets/model/logocube.glb");
const Model = (props: BoxProps) => {
const { nodes, materials } = useGLTF(
"/assets/model/logocube.glb"
) as GLTFResult;
return (
<group>
<group position={[0, 0, 0]}>
<mesh
castShadow
receiveShadow
geometry={nodes.kuang.geometry}
material={materials.gold}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.neihong.geometry}
material={materials.neise}
/>
</group>
</group>
);
};
export default Model;
使用している3Dモデルはマテリアルを分けているため、2つメッシュが存在します。テクスチャにベイクしている場合などは1つのメッシュで済みそうですね。また3Dモデルでよくあるミスが Blenderでスケールを正規化せずにエクスポートしちゃうことです。何かとずれが生じるのでスケールは正規化しましょう。今回はサイズも1に合わせたかったので、Blender側でxyzそれぞれ1mの立方体にしています。
onClick
いよいよクリックイベントを設定します。r3fはレイキャストを設定しなくてもクリックイベントを簡単に実装できるのは神過ぎます。先に床と同じ要領で物理を適用します。
const [boxRef, boxApi] = useBox(
() => ({
mass: 1,
position: [0, 10, 0],
rotation: [
Math.PI * Math.random(),
Math.PI * Math.random(),
Math.PI * Math.random(),
],
args: boxSize,
...props,
}),
useRef<Group>(null!)
);
mass
というのは質量を表しています。基本1
でいいかなと思います。
useBoxの返り値はref
とapi
です。ref
は今回の場合は<group>
にくっつけます。物理オブジェクトのプロパティや状態を制御するための機能を提供するapi
を使用してクリックイベントを実装していきます。
const handleClick = () => {
const impulse: Triplet = [1, 2, 0]; // x, y, z軸方向の力
const worldPoint: Triplet = [0, 0, 0]; // オブジェクト中心に力を適用
boxApi.applyImpulse(impulse, worldPoint);
};
api.applyImpulse()
はオブジェクト(今回はBox)に衝撃を与えることができます。第一引数に衝撃の方向ベクトル、第二引数に衝撃力が適用されるオブジェクト上の点を指定します。また、各変数をTriplet
で型付けしていますが、three
のVector3
でも大丈夫です。
では実装したサイコロをクリックしてみます。

一応は物理演算が適応されているので、今の状態でもいい感じではありますね。しかし、画面外に見切れてしまうのはよろしくないので、クリック時の衝撃が常にSceneの原点へ向くようにしてみましょう。
const onClickHandler = () => {
const target = new Vector3(0, 2, 0); // オブジェクトが向かうべきターゲット(画面の中心)
// オブジェクトの位置からターゲットへの方向ベクトルを計算
const direction = target
.clone()
.sub(new Vector3(...boxPosition))
.normalize();
// 衝撃力の大きさ
const forceMagnitude = 3.5;
const minOffset = 4.5; // 最小のオフセット値を設定
// 衝撃力を方向ベクトルに適用
const impulse: Triplet = [
forceMagnitude *
(direction.x + (direction.x >= 0 ? minOffset : -minOffset)),
forceMagnitude *
(direction.y + (direction.y >= 0 ? minOffset : -minOffset)),
forceMagnitude *
(direction.z + (direction.z >= 0 ? minOffset : -minOffset)),
];
// トルクの値をランダムに設定
const torque: Triplet = [
(Math.random() - 0.5) * Math.PI,
(Math.random() - 0.5) * Math.PI,
(Math.random() - 0.5) * Math.PI,
];
const worldPoint: Triplet = [0, 0, 0];
boxApi.applyImpulse(impulse, worldPoint);
boxApi.applyTorque(torque);
};
簡単にいうとサイコロの位置から原点までの方向ベクトルを算出し、さらにトルクと呼ばれる回転力もランダムに決めて、サイコロに設定しています。これでサイコロが画面外に吹っ飛んでいくことはほとんどなくなったと思うのですが、この記事書いてる途中に「impulse
とtorque
両方設定するんじゃなくて、torque
だけでよくないか?」と思って実装してみたら、torque
だけの実装の方が理想的なサイコロの動きだったので、そっちも紹介します。
const onClickHandler = () => {
// スケーリングファクターを追加して、トルクの大きさを調整
const torqueScalingFactor = 180;
//
const torqueWeight = 0.9;
// キューブの上方向ベクトル
const cubeUpDirection = new Vector3(0, 1, 0);
// キューブの位置から [0, 0, 0] への方向ベクトルを計算
const directionToOrigin = new Vector3(...boxPosition).negate().normalize();
// トルクを計算(外積)
const torque = cubeUpDirection
.clone()
.add(
new Vector3(
(Math.random() - 0.5) * torqueWeight,
(Math.random() - 0.5) * torqueWeight,
(Math.random() - 0.5) * torqueWeight
)
)
.cross(directionToOrigin)
.normalize() // トルクベクトルを正規化
.multiplyScalar(torqueScalingFactor)
.toArray() as Triplet;
boxApi.applyTorque(torque);
};
コメント書いてるのでそこを読めば理解はできると思いますが、トルク計算の部分でランダムベクトルを追加しているのは、ランダム要素を入れないとサイコロが直線を行ったり来たりするだけになって見栄えが悪いからです。ランダムベクトルを加えることにより、ベクトルの向きの大筋は原点に向いてるけど、ちょっとずれている状態を演出しています。
またこの実装の場合、サイコロと床のfriction
(摩擦係数)とrestitution
(反発係数)をいい感じに設定してあげなければ、摩擦が大き過ぎて動かないことがあります。
// サイコロ
const [boxRef, boxApi] = useBox(
() => ({
mass: 1,
material: {
friction: 0.1, // 摩擦係数,大きいほど摩擦が強い
restitution: 0.3, // 反発係数
},
...以下略
// 床
const [ref] = usePlane(
() => ({
material: {
friction: 0.2,
restitution: 0.2, // 反発係数
},
...以下略

うまく実装できました٩(๑>∀<๑)۶
終わりに
今回はサイコロにクリックイベントを付与して転がすところの実装を解説しました。ただ、現状の実装だと連続でクリックできちゃうので、その辺も制限かけないとダメですね。また、最近は@react-three/rapier
に注目が集まっていて、Rust製で軽いだとか聞くので、またこのサイコロも@react-three/rapier
で書き換えたいです。