Threejs实现物理运动模拟

0 阅读6分钟

一、物理运动原理

在 Three.js 中实现物理运动的核心思想是:

  1. Three.js 负责渲染:Three.js 是一个图形库,用于创建和渲染 3D 场景。
  2. 物理引擎负责计算:物理引擎(如 Cannon.js或 Ammo.js, 本文用的是前一种)模拟现实世界的物理规则,例如重力、碰撞、反弹等。
  3. 数据同步:在每一帧中,将物理引擎计算的结果(如物体的位置、速度、旋转等)同步到 Three.js 的对象上。

二、实现步骤详解

1. 初始化 Three.js 场景

initScene 方法中,创建 Three.js 的基本组件(场景、相机、渲染器等)。

function initScene() {
  if (!canvas.value) return
  makeRenderer(canvas.value.clientWidth, canvas.value.clientHeight)
  makeScene()
  makeCamera(canvas.value.clientWidth, canvas.value.clientHeight)
  orbitControls()
  addSky()
  addGround()
}
  • 初始化 WebGL 渲染器,并设置画布大小和设备像素比。 makeRenderer
  • 创建 Three.js 场景,并添加光源。 makeScene
  • 创建透视相机,并设置初始位置。 makeCamera
  • 添加轨道控制器,用于控制相机视角。 orbitControls
  • 和 :分别添加天空和地面。 addSky``addGround

2. 创建物理世界

使用 Cannon.js 创建物理世界,并设置重力。

world = new Cannon.World()
world.gravity.set(0, -9.82, 0) // 设置重力 (m/s²)
  • world.gravity.set(0, -9.82, 0):设置重力方向为向下(Y 轴负方向),模拟地球重力。

3. 创建物体并同步到物理引擎

在 Three.js 和物理引擎中分别创建物体,并保持它们之间的同步。

创建球体(Three.js 对象)
sphere = new THREE.Mesh(
  new THREE.SphereGeometry(2, 32, 32),
  new THREE.MeshBasicMaterial({ color: 0xff11ff }),
)
sphere.position.set(0, 10, 0)
scene.add(sphere)
  • 使用 THREE.SphereGeometry 创建一个半径为 2 的球体。
  • 将球体添加到 Three.js 场景中。
创建对应的物理刚体(Cannon.js 对象)
const sphereBody = new Cannon.Body({
  mass: 1,
  shape: new Cannon.Sphere(2),
  position: new Cannon.Vec3(sphere.position.x, sphere.position.y, sphere.position.z),
})
world.addBody(sphereBody)
  • 使用 Cannon.Sphere 创建一个物理刚体,并设置质量为 1。
  • 将物理刚体添加到物理世界中。
添加地面
const groundBody = new Cannon.Body({
  mass: 0,
  shape: new Cannon.Plane(),
})
groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0)
world.addBody(groundBody)
  • 创建一个静态的平面作为地面(mass: 0 表示静态物体)。
  • 使用 setFromEuler 旋转平面使其水平。

4. 动画循环

在每一帧中更新物理引擎的状态,并同步到 Three.js 场景。

function animate() {
  animationFrameId = requestAnimationFrame(animate)

  // 更新物理引擎
  updatePhysic()

  // 渲染场景
  renderer.render(scene, camera)
}

function updatePhysic() {
  world.step(1 / 60) // 模拟 60 帧每秒
  sphere.position.copy(sphereBody.position) // 同步位置
}
  • world.step(1 / 60):更新物理引擎的世界状态,模拟 60 帧每秒。
  • sphere.position.copy(sphereBody.position):将物理引擎计算的结果同步到 Three.js 的球体对象上。

5. 处理窗口大小调整

当窗口大小发生变化时,重新调整渲染器和相机的参数。

function onWindowResize() {
  if (!canvas.value) return
  const width = canvas.value.clientWidth
  const height = canvas.value.clientHeight

  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height)
}
  • 调整相机的宽高比,并更新投影矩阵。
  • 重新设置渲染器的尺寸。

三、完整流程总结

  1. 初始化 Three.js 场景
    • 创建场景、相机、渲染器。
    • 添加光源、天空、地面等元素。
  2. 初始化物理引擎
    • 创建物理世界,并设置重力。
    • 添加地面和物体的物理刚体。
  3. 动画循环
    • 在每一帧中更新物理引擎的状态。
    • 将物理引擎的结果同步到 Three.js 对象上。
    • 渲染场景。
  4. 处理窗口大小调整
    • 监听窗口大小变化事件,动态调整相机和渲染器的参数。

四、关键点解析

1. 数据同步

  • 物理引擎中的物体(如 sphereBody)和 Three.js 中的物体(如 sphere)需要保持同步。
  • 在每一帧中,通过 sphere.position.copy(sphereBody.position) 实现位置同步。

2. 性能优化

  • 使用 requestAnimationFrame 创建动画循环,确保流畅的动画效果。
  • 设置物理引擎的更新频率(如 world.step(1 / 60)),避免过度计算。

3. 物理材质

  • 在代码中,定义了两种物理材质(groundMaterialsphereMaterial),并通过 设置摩擦系数和弹性系数。 ContactMaterial
  • 这些参数影响物体的碰撞行为,例如反弹高度和滑动距离。

五、源码

<template>
  <el-container style="width: 100%; height: 100%">
    <!-- 头部标题 -->
    <el-header class="header"> 物理运动</el-header>

    <!-- 主要内容区域,包含 Three.js 画布 -->
    <el-main class="canvas-container">
      <canvas ref="canvas" class="canvas"></canvas>

    </el-main>

  </el-container>

</template>

<script lang="ts">
export default {
  name: 'PhysicalMotion', // 使用多词名称
}
</script>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { Sky } from 'three/examples/jsm/objects/Sky'
import { ShaderMaterial, MathUtils } from 'three'
import * as Cannon from 'cannon'

// Three.js 相关变量声明
// 场景
let scene: THREE.Scene
// 透视相机
let camera: THREE.PerspectiveCamera
// WebGL渲染器
let renderer: THREE.WebGLRenderer
// 天空
let sky: THREE.Mesh
// 动画帧ID
let animationFrameId: number

let controls: InstanceType<typeof OrbitControls>
let world: InstanceType<typeof Cannon.World>
let sphere: THREE.Mesh
let sphereBody: InstanceType<typeof Cannon.Body>
// 画布引用
const canvas = ref<HTMLCanvasElement>()
// 组件挂载时初始化场景并开始动画
onMounted(() => {
  initScene()
  animate()
  // 添加窗口大小改变事件监听
  window.addEventListener('resize', onWindowResize)
})

// 初始化场景
function initScene() {
  if (!canvas.value) return
  makeRenderer(canvas.value.clientWidth, canvas.value.clientHeight)
  makeScene()

  // 创建圆形
  sphere = new THREE.Mesh(
    new THREE.SphereGeometry(2, 32, 32),
    new THREE.MeshBasicMaterial({ color: 0xff11ff }),
  )
  sphere.position.set(0, 10, 0)
  scene.add(sphere)
  world = new Cannon.World()
  world.gravity.set(0, -9.82, 0)
  //创建物理材料
  const groundMaterial = new Cannon.Material("groundMaterial")
  const sphereMaterial = new Cannon.Material("sphereMaterial")
  const contactMaterial = new Cannon.ContactMaterial(groundMaterial, sphereMaterial, {
    friction: 0.3,
    restitution: 0.5,
  })
  world.addContactMaterial(contactMaterial)
  sphereBody = new Cannon.Body({
    mass: 1,
    shape: new Cannon.Sphere(2),
    position: new Cannon.Vec3(sphere.position.x, sphere.position.y, sphere.position.z),
    material: sphereMaterial,
  })
  world.addBody(sphereBody)
  const groundBody = new Cannon.Body({
    mass: 0,
    shape: new Cannon.Plane(),
    material: groundMaterial,
  })
  groundBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0)
  world.addBody(groundBody)

  makeCamera(canvas.value.clientWidth, canvas.value.clientHeight)
  orbitControls()
  addSky()
  addGround()
}
function updatePhysic(){
  world.step(1 / 60)
  sphere.position.copy(sphereBody.position)
}
function makeScene() {
  // 创建场景
  scene = new THREE.Scene()
  //添加光源
  const light = new THREE.DirectionalLight(0xffffff, 1)
  scene.add(light)
}

function makeRenderer(width: number, height: number) {
  // 创建WebGL渲染器
  renderer = new THREE.WebGLRenderer({ canvas: canvas.value, antialias: true })
  renderer.setSize(width, height)
  // 设置设备像素比,确保在高分辨率屏幕上清晰显示
  renderer.setPixelRatio(window.devicePixelRatio)
}

function makeCamera(width: number, height: number) {
  // 创建透视相机
  camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
  // 设置相机位置
  camera.position.set(0, 10, 20)
  camera.lookAt(0, 0, 0)
}

function orbitControls() {
  controls = new OrbitControls(camera, canvas.value)

  // 限制摄像机的极角范围(单位是弧度)
  controls.minPolarAngle = 0 // 最小角度,0 表示水平视角
  controls.maxPolarAngle = MathUtils.degToRad(85) // 最大角度,例如 85 度

  // 可选:限制摄像机的缩放范围
  controls.minDistance = 5 // 最小距离
  controls.maxDistance = 100 // 最大距离
}

function addSky() {
  sky = new Sky()
  sky.scale.setScalar(450000) // 设置天空的缩放比例
  scene.add(sky)

  const sun = new THREE.Vector3()

  // 配置天空参数
  const effectController = {
    turbidity: 10,
    rayleigh: 2,
    mieCoefficient: 0.005,
    mieDirectionalG: 0.8,
    elevation: 2,
    azimuth: 180,
    exposure: renderer.toneMappingExposure,
  }

  const uniforms = (sky.material as ShaderMaterial).uniforms
  uniforms['turbidity'].value = effectController.turbidity
  uniforms['rayleigh'].value = effectController.rayleigh
  uniforms['mieCoefficient'].value = effectController.mieCoefficient
  uniforms['mieDirectionalG'].value = effectController.mieDirectionalG

  const phi = THREE.MathUtils.degToRad(90 - effectController.elevation)
  const theta = THREE.MathUtils.degToRad(effectController.azimuth)

  sun.setFromSphericalCoords(1, phi, theta)

  uniforms['sunPosition'].value.copy(sun)
}

// 添加大地
function addGround() {
  const groundGeometry = new THREE.PlaneGeometry(100, 100)
  const groundMaterial = new THREE.MeshStandardMaterial({
    color: 0x7ec850, // 绿色草地颜色
    side: THREE.DoubleSide,
  })

  const ground = new THREE.Mesh(groundGeometry, groundMaterial)
  ground.rotation.x = Math.PI / 2 // 旋转平面使其水平
  scene.add(ground)
}

// 动画循环函数
function animate() {
  animationFrameId = requestAnimationFrame(animate)
  controls.update()
  updatePhysic()
  // 渲染场景
  renderer.render(scene, camera)
}

// 清理函数
function cleanup() {
  // 取消动画帧`
  if (animationFrameId) {
    cancelAnimationFrame(animationFrameId)
  }

  // 清理渲染器
  if (renderer) {
    // 移除所有事件监听器
    renderer.domElement.removeEventListener('resize', onWindowResize)
    // 释放渲染器资源
    renderer.dispose()
    // 清空渲染器
    renderer.forceContextLoss()
    // 移除画布
    renderer.domElement.remove()
  }

  // 清理场景
  if (scene) {
    // 遍历场景中的所有对象
    scene.traverse((object) => {
      if (object instanceof THREE.Mesh) {
        // 释放几何体资源
        object.geometry.dispose()
        // 释放材质资源
        if (Array.isArray(object.material)) {
          object.material.forEach((material) => material.dispose())
        } else {
          object.material.dispose()
        }
      }
    })
    // 清空场景
    scene.clear()
  }

  // 清理相机
  if (camera) {
    camera.clear()
  }
}

// 窗口大小改变时的处理函数
function onWindowResize() {
  if (!canvas.value) return
  const width = canvas.value.clientWidth
  const height = canvas.value.clientHeight
  console.log('onWindowResize', width, height)
  // 更新相机
  camera.aspect = width / height
  camera.updateProjectionMatrix()

  // 更新渲染器
  renderer.setSize(width, height)
  // 确保渲染器的像素比与设备匹配
  renderer.setPixelRatio(window.devicePixelRatio)
}

// 组件卸载时清理资源
onUnmounted(() => {
  cleanup()
  // 移除窗口大小改变事件监听
  window.removeEventListener('resize', onWindowResize)
})
</script>

<style scoped>
/* 头部样式 */
.header {
  text-align: center;
  font-size: 20px;
  font-weight: bold;
  color: #333;
}

/* 画布容器样式 */
.canvas-container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 画布样式   // 保持画布比例*/
.canvas {
  width: 100%;
  height: 100%;
}
</style>