Vật lý

Starter pack

Vật lý mở ra một thế giới hoàn toàn mới về khả năng cho các dự án 3D của bạn. Bạn có thể tạo ra các vũ trụ thực tế, tương tác người dùng và thậm chí cả trò chơi.

Trong bài học này, chúng ta sẽ khám phá các khái niệm cơ bản khi xây dựng một trò chơi đơn giản.

Đừng lo lắng, bạn không cần kiến thức trước đó, chúng ta sẽ bắt đầu từ đầu. (Để bạn biết, tôi đã từng là một học sinh rất kém môn vật lý ở trường, nên nếu tôi có thể làm được, bạn cũng có thể làm được!)

Động cơ vật lý

Để thêm vật lý vào các dự án 3D của chúng ta, chúng ta sẽ sử dụng một động cơ vật lý. Một động cơ vật lý là một thư viện sẽ xử lý tất cả các phép toán phức tạp cho chúng ta, như lực hấp dẫn, va chạm, lực, v.v.

Trong hệ sinh thái JavaScript, có rất nhiều động cơ vật lý sẵn có.

Hai động cơ rất phổ biến là Cannon.jsRapier.js.

Poimandres (một lần nữa) đã tạo ra hai thư viện tuyệt vời để sử dụng các động cơ này với React Three Fiber: react-three-rapieruse-cannon.

Trong bài học này, chúng ta sẽ sử dụng react-three-rapier nhưng chúng rất giống nhau và các khái niệm chúng ta học ở đây có thể áp dụng cho cả hai.

Để cài đặt nó, chạy:

yarn add @react-three/rapier

Giờ chúng ta đã sẵn sàng để bắt đầu!

Thế Giới Vật Lý

Trước khi tạo trò chơi, hãy cùng xem qua những khái niệm cơ bản.

Đầu tiên, chúng ta cần tạo một thế giới vật lý. Thế giới này sẽ chứa tất cả các đối tượng vật lý của cảnh của chúng ta. Với react-three-rapier, chúng ta chỉ cần bao bọc tất cả các đối tượng của chúng ta với một component <Physics>:

// ...
import { Physics } from "@react-three/rapier";

function App() {
  return (
    <>
      <Canvas camera={{ position: [0, 6, 6], fov: 60 }} shadows>
        <color attach="background" args={["#171720"]} />
        <Physics>
          <Experience />
        </Physics>
      </Canvas>
    </>
  );
}

export default App;

Thế giới của chúng ta bây giờ đã sẵn sàng nhưng chưa có gì xảy ra! Đó là vì chúng ta chưa có đối tượng vật lý nào.

Rigidbody

Để thêm vật lý cho một đối tượng, chúng ta cần thêm một rigidbody. Rigidbody là một thành phần giúp đối tượng của chúng ta di chuyển trong thế giới vật lý.

Điều gì có thể kích hoạt chuyển động của một đối tượng? Lực, chẳng hạn như trọng lực, va chạm, hoặc tương tác của người dùng.

Hãy báo cho thế giới vật lý của chúng ta biết rằng hình khối trong Player.jsx là một đối tượng vật lý bằng cách thêm một rigidbody vào nó:

import { RigidBody } from "@react-three/rapier";

export const Player = () => {
  return <RigidBody>{/* ... */}</RigidBody>;
};

Bây giờ hình khối của chúng ta đang chịu tác động của trọng lực và đang rơi xuống. Nhưng nó đang rơi vô tận!

Chúng ta cần làm cho mặt đất trở thành một đối tượng vật lý để hình khối có thể va chạm với nó và ngừng rơi.

Hãy thêm rigidbody vào mặt đất trong Experience.jsx, nhưng vì chúng ta không muốn nó di chuyển và rơi như hình khối, chúng ta sẽ thêm thuộc tính type="fixed":

// ...
import { RigidBody } from "@react-three/rapier";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      <RigidBody type="fixed">
        <mesh position-y={-0.251} receiveShadow>
          <boxGeometry args={[20, 0.5, 20]} />
          <meshStandardMaterial color="mediumpurple" />
        </mesh>
      </RigidBody>
      {/* ... */}
    </>
  );
};

Hình khối trên mặt đất

Quay trở lại điểm bắt đầu, chúng ta có một hình khối không di chuyển trên mặt đất. Nhưng, dưới lớp biểu diễn, chúng ta có một hình khối phản ứng với trọng lực và bị dừng lại bởi sự va chạm với mặt đất.

Lực

Bây giờ chúng ta có một thế giới vật lý và các đối tượng vật lý, chúng ta có thể bắt đầu chơi với các lực tác động.

Chúng ta sẽ làm cho hình khối di chuyển bằng các phím mũi tên trên bàn phím. Để làm điều đó, hãy sử dụng KeyboardControls mà chúng ta đã khám phá trong bài học sự kiện:

// ...
import { KeyboardControls } from "@react-three/drei";
import { useMemo } from "react";

export const Controls = {
  forward: "forward",
  back: "back",
  left: "left",
  right: "right",
  jump: "jump",
};

function App() {
  const map = useMemo(
    () => [
      { name: Controls.forward, keys: ["ArrowUp", "KeyW"] },
      { name: Controls.back, keys: ["ArrowDown", "KeyS"] },
      { name: Controls.left, keys: ["ArrowLeft", "KeyA"] },
      { name: Controls.right, keys: ["ArrowRight", "KeyD"] },
      { name: Controls.jump, keys: ["Space"] },
    ],
    []
  );
  return <KeyboardControls map={map}>{/* ... */}</KeyboardControls>;
}

export default App;

Bây giờ chúng ta có thể lấy các phím đã nhấn trong thành phần Player.jsx của mình:

// ...
import { Controls } from "../App";
import { useKeyboardControls } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";

export const Player = () => {
  const [, get] = useKeyboardControls();

  useFrame(() => {
    if (get()[Controls.forward]) {
    }
    if (get()[Controls.back]) {
    }
    if (get()[Controls.left]) {
    }
    if (get()[Controls.right]) {
    }
    if (get()[Controls.jump]) {
    }
  });
  // ...
};

get() là cách khác để lấy các phím đã nhấn với thành phần KeyboardControls.

Bây giờ khi chúng ta đã có các phím đã nhấn, chúng ta có thể áp dụng lực lên hình khối. Chúng ta có thể làm điều đó với hai phương pháp:

  • applyImpulse: áp dụng lực tức thời lên đối tượng
  • setLinVel: thiết lập vận tốc tuyến tính cho đối tượng

Hãy khám phá cả hai phương pháp này.

Hãy thêm một useRef vào RigidBody, và sử dụng nó để áp dụng một cú đẩy để di chuyển hình khối theo hướng xác định:

import { useRef } from "react";
import { Vector3 } from "three";
const MOVEMENT_SPEED = 0.5;

export const Player = () => {
  const rb = useRef();
  const [, get] = useKeyboardControls();
  const impulse = new Vector3();
  useFrame(() => {
    impulse.x = 0;
    impulse.y = 0;
    impulse.z = 0;
    if (get()[Controls.forward]) {
      impulse.z -= MOVEMENT_SPEED;
    }
    if (get()[Controls.back]) {
      impulse.z += MOVEMENT_SPEED;
    }
    if (get()[Controls.left]) {
      impulse.x -= MOVEMENT_SPEED;
    }
    if (get()[Controls.right]) {
      impulse.x += MOVEMENT_SPEED;
    }
    if (get()[Controls.jump]) {
    }
    rb.current.applyImpulse(impulse, true);
  });
  return <RigidBody ref={rb}>{/* ... */}</RigidBody>;
};

Hãy cẩn thận gán ref cho RigidBody và không phải cho mesh.

Nó hoạt động, nhưng nó tăng tốc quá nhanh và trượt trên mặt đất. Chúng ta có thể thêm nhiều ma sát hơn vào mặt đất để khắc phục điều đó:

// ...

export const Experience = () => {
  // ...
  return (
    <>
      {/* ... */}
      <RigidBody type="fixed" friction={5}>
        {/* ... */}
      </RigidBody>
      {/* ... */}
    </>
  );
};

Ma sát làm cho hình khối xoay vì nó bám vào mặt đất. Chúng ta có thể khắc phục điều đó bằng cách khóa quay của hình khối:

// ...
export const Player = () => {
  // ...
  return (
    <RigidBody ref={rb} lockRotations>
      {/* ... */}
    </RigidBody>
  );
};

Nó bây giờ tốt hơn nhiều, nhưng hình khối vẫn còn trượt một chút. Chúng ta có thể khắc phục điều đó bằng cách điều chỉnh linear damping cho hình khối nhưng chúng ta sẽ không theo đuổi con đường này trong bài học này.

Bởi vì chúng ta cũng sẽ cần điều chỉnh tốc độ tối đa của hình khối để ngăn nó không tăng tốc liên tục. Chúng ta sẽ gặp vấn đề khi sử dụng các phím trái và phải để xoay hình khối thay vì di chuyển nó.

Hãy chuyển hệ thống của chúng ta sang sử dụng setLinVel thay vì applyImpulse:

// ...
import { useRef } from "react";
import { Vector3 } from "three";

const MOVEMENT_SPEED = 5;

export const Player = () => {
  // ...
  const rb = useRef();
  const vel = new Vector3();
  useFrame(() => {
    vel.x = 0;
    vel.y = 0;
    vel.z = 0;
    if (get()[Controls.forward]) {
      vel.z -= MOVEMENT_SPEED;
    }
    if (get()[Controls.back]) {
      vel.z += MOVEMENT_SPEED;
    }
    if (get()[Controls.left]) {
      vel.x -= MOVEMENT_SPEED;
    }
    if (get()[Controls.right]) {
      vel.x += MOVEMENT_SPEED;
    }
    if (get()[Controls.jump]) {
    }
    rb.current.setLinvel(vel, true);
  });
  return <RigidBody ref={rb}>{/* ... */}</RigidBody>;
};

Bạn cũng có thể loại bỏ ma sát từ mặt đất vì nó không cần thiết nữa.

F2 là bạn tốt để nhanh chóng đổi tên biến.

Tuyệt vời! Bây giờ chúng ta đã có một hình khối có thể di chuyển bằng các phím mũi tên trên bàn phím.

Hãy thêm một lực nhảy khi người dùng nhấn phím cách:

// ...
const JUMP_FORCE = 8;

export const Player = () => {
  // ...
  useFrame(() => {
    // ...
    if (get()[Controls.jump]) {
      vel.y += JUMP_FORCE;
    }
    rb.current.setLinvel(vel, true);
  });
  return (
    <RigidBody ref={rb} lockRotations>
      {/* ... */}
    </RigidBody>
  );
};

Chúng ta có hai vấn đề:

  • Hình khối không phản ứng đúng với trọng lực (so sánh với hình khối rơi lúc đầu bài học)
  • Hình khối có thể nhảy khi nó đã đang ở trên không trung

Vấn đề trọng lực là vì chúng ta đặt tốc độ của hình khối theo trục y một cách thủ công. Chúng ta cần thay đổi nó chỉ khi nào nhảy, và để động cơ vật lý xử lý trọng lực trong những lúc khác:

// ...

export const Player = () => {
  // ...
  useFrame(() => {
    const curVel = rb.current.linvel();
    if (get()[Controls.jump]) {
      vel.y += JUMP_FORCE;
    } else {
      vel.y = curVel.y;
    }
    rb.current.setLinvel(vel, true);
  });
  // ...
};

Chúng ta lấy tốc độ hiện tại của hình khối với rb.current.linvel() và nếu không nhảy, chúng ta gán tốc độ y bằng tốc độ hiện tại.

Để ngăn hình khối nhảy khi đang ở trên không trung, chúng ta có thể kiểm tra xem hình khối có chạm đất trước khi có thể nhảy lại hay không:

// ...
export const Player = () => {
  // ...
  const inTheAir = useRef(false);
  useFrame(() => {
    // ...
    if (get()[Controls.jump] && !inTheAir.current) {
      vel.y += JUMP_FORCE;
      inTheAir.current = true;
    } else {
      vel.y = curVel.y;
    }
    rb.current.setLinvel(vel, true);
  });
  // ...
};

Bây giờ chúng ta chỉ có thể nhảy một lần, và trọng lực hoạt động nhưng hơi chậm.

Trước khi sửa chúng, hãy xem hệ thống va chạm của chúng ta hoạt động như thế nào.

Va chạm

Colliders có trách nhiệm phát hiện va chạm giữa các đối tượng. Chúng được gắn vào thành phần RigidBody.

Rapier tự động thêm một va chạm vào thành phần RigidBody dựa trên hình học của mesh nhưng chúng ta cũng có thể thêm chúng thủ công.

Để hiển thị các va chạm, chúng ta có thể sử dụng thuộc tính debug trên thành phần Physics:

// ...

function App() {
  // ...
  return (
    <KeyboardControls map={map}>
      <Canvas camera={{ position: [0, 6, 6], fov: 60 }} shadows>
        <color attach="background" args={["#171720"]} />
        <Physics debug>
          <Experience />
        </Physics>
      </Canvas>
    </KeyboardControls>
  );
}

export default App;

Bởi vì chúng ta sử dụng hình học box cho cube và mặt đất, các va chạm hiện tại bao bọc chúng hoàn hảo và chúng ta khó có thể thấy màu đường viền của chúng từ chế độ debug.

Hãy chuyển đổi va chạm của cube sang va chạm sphere:

import { vec3 } from "@react-three/rapier";
// ...

export const Player = () => {
  // ...
  return (
    <RigidBody ref={rb} lockRotations colliders={"ball"}>
      {/* ... */}
    </RigidBody>
  );
};

Cách này là bán tự động, chúng ta cho rapier biết loại va chạm mà chúng ta muốn và nó sẽ tự động tạo ra dựa trên kích thước của mesh.

Chúng ta cũng có thể thêm va chạm thủ công và điều chỉnh nó:

import { BallCollider } from "@react-three/rapier";
// ...

export const Player = () => {
  // ...
  return (
    <RigidBody ref={rb} lockRotations colliders={false}>
      {/* ... */}
      <BallCollider args={[1.5]} />
    </RigidBody>
  );
};

Chúng ta đặt collidersfalse để ngăn rapier tự động tạo va chạm.

Ball collider bao bọc cube của chúng ta

Các loại va chạm khác nhau là:

  • box: va chạm box
  • ball: va chạm sphere
  • hull: nghĩ về nó như một lớp quấn quà quanh mesh của bạn
  • trimesh: va chạm sẽ bao bọc mesh của bạn hoàn hảo

Luôn sử dụng va chạm đơn giản nhất có thể để cải thiện hiệu suất.

Bây giờ khi chúng ta đã biết va chạm của cube và mặt đất, hãy phát hiện va chạm giữa chúng để biết khi nào cube đang ở trên mặt đất.

Hãy loại bỏ ball collider từ cube và thêm thuộc tính onCollisionEnter vào RigidBody:

// ...

export const Player = () => {
  // ...
  return (
    <RigidBody
    {/* ... */}
      onCollisionEnter={({ other }) => {
        if (other.rigidBodyObject.name === "ground") {
          inTheAir.current = false;
        }
      }}
    >
      {/* ... */}
    </RigidBody>
  );
};

Chúng ta có thể truy cập va chạm khác với other.rigidBodyObject và kiểm tra tên của nó để biết đây có phải là mặt đất không.

Chúng ta cần thêm tên cho va chạm mặt đất:

// ...
export const Experience = () => {
  return (
    <>
      {/* ... */}
      <RigidBody type="fixed" name="ground">
        {/* ... */}
      </RigidBody>
      {/* ... */}
    </>
  );
};

Bây giờ chúng ta có thể nhảy lại khi chạm đất.

Trọng lực

Trọng lực được phát hiện bởi Isaac Newton vào năm 1687... 🍎

Đùa chút thôi, chúng ta biết trọng lực là gì mà.

Chúng ta có hai lựa chọn để thay đổi trọng lực, có thể thay đổi nó trên toàn cục bằng cách sử dụng thuộc tính gravity trên component Physics:

<Physics debug gravity={[0, -50, 0]}>

Nó nhận một mảng gồm ba số, mỗi số cho một trục. Ví dụ, bạn có thể tạo hiệu ứng gió mà chỉ những vật có khối lượng nhỏ mới bị ảnh hưởng. Bạn cũng có thể tạo hiệu ứng trọng lực của mặt trăng bằng cách thiết lập giá trị thấp hơn cho trục y.

Nhưng trọng lực mặc định là thực tế và hoạt động tốt cho trò chơi của chúng ta, vì vậy chúng ta sẽ giữ nguyên và tác động trọng lực lên hình khối của chúng ta với thuộc tính gravityScale trên RigidBody:

// ...

export const Player = () => {
  // ...
  return (
    <RigidBody
      // ...
      gravityScale={2.5}
    >
      {/* ... */}
    </RigidBody>
  );
};

Bây giờ chuyển động nhảy trông khá thuyết phục!

Hãy thêm một quả bóng để xem chúng tương tác với nhau như thế nào:

// ...
export const Experience = () => {
  return (
    <>
      {/* ... */}
      <RigidBody
        colliders={false}
        position-x={3}
        position-y={3}
        gravityScale={0.2}
        restitution={1.2}
        mass={1}
      >
        <Gltf src="/models/ball.glb" castShadow />
        <BallCollider args={[1]} />
      </RigidBody>
      {/* ... */}
    </>
  );
};

Chúng ta cần tạo thủ công bộ va chạm cho quả bóng bởi vì dù nó trông như quả bóng, mô hình phức tạp hơn và Rapier không tự động tạo bộ va chạm đúng cho nó.

Điều chỉnh mức độ restitution để làm cho quả bóng nảy nhiều hoặc ít hơn, và gravityScale để làm cho nó rơi nhanh hoặc chậm hơn.

Trông tuyệt!

Đã đến lúc tạo trò chơi của chúng ta!

Trò chơi

Để xây dựng trò chơi này, tôi đã chuẩn bị một bản đồ trong Blender sử dụng các tài sản từ Mini-Game Variety Pack từ Kay Lousberg.

Kay-kit mini game variety pack

Đây là một gói tài sản tuyệt vời miễn phí bản quyền với rất nhiều tài sản, tôi rất khuyên dùng!

Sân chơi

Hãy sử dụng component Playground trong Experience, và loại bỏ mặt đất:

import { Playground } from "./Playground";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      {/* <RigidBody type="fixed" name="ground">
        <mesh position-y={-0.251} receiveShadow>
          <boxGeometry args={[20, 0.5, 20]} />
          <meshStandardMaterial color="mediumpurple" />
        </mesh>
      </RigidBody> */}
      <Playground />
    </>
  );
};

Không có gì đặc biệt ở đây, đây chỉ là mã được tạo ra từ gltfjsxmô hình 3D, tôi chỉ thêm thủ công các thuộc tính receiveShadowcastShadow vào các mesh.

Playground

Chúng ta có sân chơi, nhưng không có mặt đất nữa. Chúng ta cần bao bọc các playground meshes bằng một RigidBody:

// ...
import { RigidBody } from "@react-three/rapier";

export function Playground(props) {
  // ...
  return (
    <group {...props} dispose={null}>
      <RigidBody type="fixed" name="ground">
        {/* ... */}
      </RigidBody>
    </group>
  );
}

// ...

Nó bao bọc mọi mesh bằng RigidBody và thêm một box collider cho mỗi mesh. Nhưng vì chúng ta có nhiều hình dạng phức tạp hơn, chúng ta sẽ sử dụng trimesh collider thay thế:

<RigidBody type="fixed" name="ground" colliders="trimesh">

Trimesh collider

Bây giờ mỗi mesh đều được bao bọc hoàn hảo.

Bộ điều khiển góc nhìn thứ ba

Để trò chơi của chúng ta có thể chơi được, chúng ta cần thêm một bộ điều khiển góc nhìn thứ ba vào khối lập phương của chúng ta.

Hãy làm cho camera theo dõi người chơi của chúng ta. Khi di chuyển RigidBody của nó, ta có thể chèn camera bên trong:

// ...
import { PerspectiveCamera } from "@react-three/drei";

export const Player = () => {
  // ...
  return (
    <RigidBody
    // ...
    >
      <PerspectiveCamera makeDefault position={[0, 5, 8]} />
      {/* ... */}
    </RigidBody>
  );
};

Vị trí của nó hoạt động hoàn hảo, nhưng mặc định, camera đang nhìn vào gốc tọa độ [0, 0, 0] và chúng ta muốn nó nhìn vào khối lập phương.

Chúng ta cần cập nhật nó mỗi frame. Để làm điều này, chúng ta có thể tạo một ref trên camera và sử dụng hook useFrame của chúng ta:

// ...

export const Player = () => {
  const camera = useRef();
  const cameraTarget = useRef(new Vector3(0, 0, 0));
  // ...

  useFrame(() => {
    cameraTarget.current.lerp(vec3(rb.current.translation()), 0.5);
    camera.current.lookAt(cameraTarget.current);
    // ...
  });
  return (
    <RigidBody
    // ...
    >
      <PerspectiveCamera makeDefault position={[0, 5, 8]} ref={camera} />
      {/* ... */}
    </RigidBody>
  );
};

Để lấy vị trí của khối lập phương, vì nó là một đối tượng Rapier, chúng ta cần sử dụng rb.current.translation(). Chúng ta sử dụng phương thức vec3 để chuyển đổi vector Rapier sang vector three.js.

Chúng ta sử dụng lerp và một cameraTarget để di chuyển camera một cách mượt mà đến vị trí của khối. Chúng ta sử dụng lookAt để làm cho camera nhìn vào khối.

Bây giờ camera theo dõi khối lập phương một cách đúng đắn, nhưng hệ thống di chuyển của chúng ta không phải là tối ưu nhất cho loại trò chơi này.

Thay vì sử dụng các phím mũi tên lên/xuống để di chuyển trên trục z, và các phím mũi tên trái/phải để di chuyển trên trục x, chúng ta sẽ sử dụng các phím mũi tên trái/phải để xoay người chơi và các phím mũi tên lên/xuống để di chuyển tiến/lùi:

// ...
import { euler, quat } from "@react-three/rapier";
// ...

const ROTATION_SPEED = 5;

export const Player = () => {
  // ...
  useFrame(() => {
    cameraTarget.current.lerp(vec3(rb.current.translation()), 0.5);
    camera.current.lookAt(cameraTarget.current);

    const rotVel = {
      x: 0,
      y: 0,
      z: 0,
    };

    const curVel = rb.current.linvel();
    vel.x = 0;
    vel.y = 0;
    vel.z = 0;
    if (get()[Controls.forward]) {
      vel.z -= MOVEMENT_SPEED;
    }
    if (get()[Controls.back]) {
      vel.z += MOVEMENT_SPEED;
    }
    if (get()[Controls.left]) {
      rotVel.y += ROTATION_SPEED;
    }
    if (get()[Controls.right]) {
      rotVel.y -= ROTATION_SPEED;
    }

    rb.current.setAngvel(rotVel, true);
    // áp dụng xoay cho x và z để đi đúng hướng
    const eulerRot = euler().setFromQuaternion(quat(rb.current.rotation()));
    vel.applyEuler(eulerRot);

    if (get()[Controls.jump] && !inTheAir.current) {
      vel.y += JUMP_FORCE;
      inTheAir.current = true;
    } else {
      vel.y = curVel.y;
    }

    rb.current.setLinvel(vel, true);
  });
  // ...
};

Hãy chia nhỏ nó ra:

  • Chúng ta tạo một biến rotVel để lưu trữ tốc độ xoay
  • Chúng ta thay đổi tốc độ xoay trên trục y khi người dùng nhấn phím mũi tên trái hoặc phải
  • Chúng ta áp dụng tốc độ xoay cho khối lập phương với rb.current.setAngvel(rotVel, true)
  • Chúng ta lấy góc xoay hiện tại của khối lập phương với rb.current.rotation()
  • Chúng ta chuyển đổi nó sang góc euler với euler().setFromQuaternion
  • Chúng ta áp dụng xoay vào vận tốc với applyEuler để chuyển đổi nó thành hướng đúng

Tái sinh

Hiện tại, nếu chúng ta rơi khỏi sân chơi, chúng ta sẽ rơi mãi mãi. Chúng ta cần thêm hệ thống tái sinh.

Chúng ta sẽ thêm các RigidBodies rất lớn dưới sân chơi. Khi người chơi tiếp xúc với nó, chúng ta sẽ dịch chuyển người chơi đến điểm bắt đầu:

// ...
import { CuboidCollider } from "@react-three/rapier";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      <RigidBody
        type="fixed"
        colliders={false}
        sensor
        name="space"
        position-y={-5}
      >
        <CuboidCollider args={[50, 0.5, 50]} />
      </RigidBody>
      {/* ... */}
    </>
  );
};

Chúng ta đã đặt tên cho RigidBodyspace và đặt nó như một sensor. Sensor không có tác động đến thế giới vật lý, nó chỉ được dùng để phát hiện va chạm.

Bây giờ trong component Player, chúng ta có thể sử dụng prop onIntersectionEnter trên RigidBody và gọi hàm respawn:

// ...

export const Player = () => {
  // ...

  const respawn = () => {
    rb.current.setTranslation({
      x: 0,
      y: 5,
      z: 0,
    });
  };
  return (
    <RigidBody
      // ...
      onIntersectionEnter={({ other }) => {
        if (other.rigidBodyObject.name === "space") {
          respawn();
        }
      }}
    >
      {/* ... */}
    </RigidBody>
  );
};

Chúng ta sử dụng phương thức setTranslation để dịch chuyển khối lập phương đến điểm bắt đầu.

onIntersectionEnter là tương đương va chạm của onCollisionEnter cho các sensor.

Sẵn sàng nhảy vào khoảng trống chưa?

Hệ thống tái sinh của chúng ta hoạt động! Bắt đầu giống như một trò chơi.

Swiper

Swiper dễ thương này được thiết kế để đẩy chúng ta ra khỏi khu vui chơi!

Swiper

Vì chúng ta cần áp dụng một lực để làm nó chuyển động, chúng ta sẽ di chuyển nó vào RigidBody riêng của nó:

import React, { useRef } from "react";

export function Playground(props) {
  // ...
  const swiper = useRef();
  return (
    <group {...props} dispose={null}>
      <RigidBody
        type="kinematicVelocity"
        colliders={"trimesh"}
        ref={swiper}
        restitution={3}
        name="swiper"
      >
        <group
          name="swiperDouble_teamRed"
          rotation-y={Math.PI / 4}
          position={[0.002, -0.106, -21.65]}
        >
          {/* ... */}
        </group>
      </RigidBody>
      {/* ... */}
    </group>
  );
}

Loại kinematicVelocity là một loại RigidBody đặc biệt có thể được di chuyển với phương thức setLinvelsetAngvel nhưng sẽ không bị ảnh hưởng bởi ngoại lực. (Nó ngăn không cho người chơi của chúng ta di chuyển swiper)

Hãy định nghĩa vận tốc góc của swiper trong component Experience của chúng ta:

import React, { useEffect, useRef } from "react";

export function Playground(props) {
  // ...
  useEffect(() => {
    swiper.current.setAngvel({ x: 0, y: 3, z: 0 }, true);
  });
  // ...
}

Nếu bạn băn khoăn tại sao chúng ta sử dụng useEffect thay vì useFrame, thì đó là vì tốc độ là hằng số, miễn là chúng ta không thay đổi nó, nó sẽ tiếp tục xoay.

Nó xoay rồi! Hãy thoải mái điều chỉnh giá trị y để thay đổi tốc độ xoay.

Hiệu ứng khi chúng ta bị đẩy ra khỏi khu vui chơi không tự nhiên. Đây là một nhược điểm của việc sử dụng setLinvel trên player thay vì applyImpulse nhưng nó cũng đơn giản hóa nhiều thứ.

Những gì chúng ta thấy là: Khối lập phương bị đẩy và phóng đi, rồi nó ngừng ngay lập tức vì setLinvel của chúng ta hủy bỏ nó.

Một giải pháp nhanh là vô hiệu hóa setLinvel của chúng ta khi chúng ta bị đẩy trong một khoảng thời gian ngắn:

// ...
export const Player = () => {
  // ...
  const punched = useRef(false);

  useFrame(() => {
    // ...

    if (!punched.current) {
      rb.current.setLinvel(vel, true);
    }
  });

  // ...
  return (
    <RigidBody
      // ...
      onCollisionEnter={({ other }) => {
        if (other.rigidBodyObject.name === "ground") {
          inTheAir.current = false;
        }
        if (other.rigidBodyObject.name === "swiper") {
          punched.current = true;
          setTimeout(() => {
            punched.current = false;
          }, 200);
        }
      }}
      // ...
    >
      {/* ... */}
    </RigidBody>
  );
};

Hiệu ứng giờ đã tốt hơn rất nhiều!

Cổng

Hãy hoàn thiện trò chơi của chúng ta bằng cách dịch chuyển người chơi đến vạch đích khi họ đi vào cổng.

Chúng ta bắt đầu bằng cách tách cổng ra khỏi rigidbody của sân chơi chính, và tạo một rigidbody sensor mới với một collider tùy chỉnh:

// ...
import { CuboidCollider } from "@react-three/rapier";

export function Playground(props) {
  // ...
  return (
    <group {...props} dispose={null}>
      {/* ... */}
      <RigidBody
        type="fixed"
        name="gateIn"
        sensor
        colliders={false}
        position={[-20.325, -0.249, -28.42]}
      >
        <mesh
          receiveShadow
          castShadow
          name="gateLargeWide_teamBlue"
          geometry={nodes.gateLargeWide_teamBlue.geometry}
          material={materials["Blue.020"]}
          rotation={[0, 1.571, 0]}
        />
        <CuboidCollider position={[-1, 0, 0]} args={[0.5, 2, 1.5]} />
      </RigidBody>
      {/* ... */}
    </group>
  );
}

Gate collider

Chúng ta có thể thấy khu vực mà va chạm sẽ được phát hiện.

Bây giờ trong mã Player của mình, chúng ta có thể xử lý kịch bản này:

// ...
import { useThree } from "@react-three/fiber";

export const Player = () => {
  // ...

  const respawn = () => {
    rb.current.setTranslation({
      x: 0,
      y: 5,
      z: 0,
    });
  };

  const scene = useThree((state) => state.scene);
  const teleport = () => {
    const gateOut = scene.getObjectByName("gateLargeWide_teamYellow");
    rb.current.setTranslation(gateOut.position);
  };

  return (
    <RigidBody
      // ...
      onIntersectionEnter={({ other }) => {
        if (other.rigidBodyObject.name === "space") {
          respawn();
        }
        if (other.rigidBodyObject.name === "gateIn") {
          teleport();
        }
      }}
    >
      {/* ... */}
    </RigidBody>
  );
};

Chúng ta sử dụng getObjectByName để lấy vị trí cổng và dịch chuyển người chơi đến đó.

Hệ thống cổng của chúng ta hoạt động!

Shadows

Bạn có thể đã nhận thấy rằng shadows của chúng ta không hoạt động đúng cách. Khu vực thử nghiệm của chúng ta quá lớn so với cài đặt shadow mặc định.

Shadows cut

Bóng đổ bị cắt.

Để khắc phục điều đó, chúng ta cần điều chỉnh cài đặt shadow camera:

// ...
import { useHelper } from "@react-three/drei";
import { useRef } from "react";
import * as THREE from "three";

export const Experience = () => {
  const shadowCameraRef = useRef();
  useHelper(shadowCameraRef, THREE.CameraHelper);

  return (
    <>
      <directionalLight
        position={[-50, 50, 25]}
        intensity={0.4}
        castShadow
        shadow-mapSize-width={1024}
        shadow-mapSize-height={1024}
      >
        <PerspectiveCamera
          ref={shadowCameraRef}
          attach={"shadow-camera"}
          near={55}
          far={86}
          fov={80}
        />
      </directionalLight>
      <directionalLight position={[10, 10, 5]} intensity={0.2} />
      {/* ... */}
    </>
  );
};

Sẽ rất khó để tìm được giá trị đúng mà không có CameraHelper.

Để tìm chúng, bạn cần có toàn bộ cảnh nằm giữa các mặt phẳng near và far được vẽ bởi helper.

Tham khảo bài học shadows nếu bạn cần ôn lại cách hoạt động của shadows.

Shadows helper

Bây giờ bóng đổ đã hoạt động đúng cách...

...và trò chơi của chúng ta đã hoàn thành! 🎉

Kết luận

Bây giờ bạn đã có những kiến thức cơ bản để tạo các mô phỏng vật lý và trò chơi của riêng bạn với React Three Fiber!

Đừng dừng lại ở đây, có rất nhiều thứ bạn có thể làm để cải thiện trò chơi này và làm cho nó thú vị hơn:

  • Thêm bộ đếm thời gian và hệ thống điểm số tốt nhất
  • Tạo các cấp độ khác bằng cách sử dụng các tài nguyên
  • Tạo một hoạt hình cool khi người chơi bấm nút cuối cùng
  • Thêm các chướng ngại vật khác
  • Thêm NPCs

Khám phá các engine và thư viện vật lý khác để tìm ra cái phù hợp nhất với nhu cầu của bạn.

Tôi đã tạo các hướng dẫn trò chơi trên kênh YouTube của tôi sử dụng React Three Fiber. Bạn có thể kiểm tra chúng nếu bạn muốn học thêm về vật lý, trò chơi, và đa người chơi.

Three.js logoReact logo

React Three Fiber: The Ultimate Guide to 3D Web Development

✨ You have reached the end of the preview ✨

Go to the next level with Three.js and React Three Fiber!

Get full access to this lesson and the complete course when you enroll:

  • 🔓 Full lesson videos with no limits
  • 💻 Access to the final source code
  • 🎓 Course progress tracking & completion
  • 💬 Invite to our private Discord community
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.