Skip to content

Threejs 从零开始实现第三人称漫游

这篇文章仅使用与初学者。这个项目是我初学 Threejs 写的一个 Demo,因此可能有不合理的地方。 这篇文章会描述我实现该功能的思路,如果有不同的见解或者有错误(实现不合理)的地方,欢迎提 issue。

主体框架

这里可能需要一些前置知识,对 Threejs 有一定的了解,知道什么是场景?什么是相机?什么是模型?

简单概要一下这些是什么东西

Camera:相机。Threejs 内置多种相机,就好比是玩家的眼睛,没有相机就乌漆麻黑什么都看不到。

Geometry:几何图像。可以理解为就是一个物体。

Light:光。

Material:材质。这个就是用来定义这个物体是什么材质的,比如皮质、金属材质的。

Texture:贴图。类似皮肤,就好像人穿不同的衣服。

Mesh:网格。物体的类,需要结合 Geometry、Material,比如定义一个正方体 Geometry,如果没有材质是看不见的(当然也不允许添加到场景),因此我们要定义一个物体,需要告诉 Threejs 它是什么形状(Geometry) ,它是什么材质(Material),然后进行网格化(Mesh)才能形成一个物体。

Scene:场景。类似一个画布,你可以在里面放任何东西。(必须存在一个)

🌐 详细请看官方文档

javascript
import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' 

const deviceInfo = {
  width: window.innerWidth,
  height: window.innerHeight,
  pixelRatio: Math.min(window.devicePixelRatio, 2),
}

const scene = new THREE.Scene()
const renderer = new THREE.WebGLRenderer()
const camera = new THREE.PerspectiveCamera()
const clock = new THREE.Clock()
const orbitControls = new OrbitControls(camera, renderer.domElement)
orbitControls.enableDamping = true

/**
 * @description: 初始化相机
 * @return {*}
 */
function initCamera() {
  camera.fov = 75
  camera.aspect = deviceInfo.width / deviceInfo.height
  camera.updateProjectionMatrix()
}

/**
 * @description: 初始化环境其他元素
 * @return {*}
 */
function initSceneOtherEffects() {
  const ambientLight = new THREE.AmbientLight(0xffffff)
  const directionalLight = new THREE.DirectionalLight(0xffc766, 0.6)
  directionalLight.castShadow = true
  directionalLight.shadow.mapSize.set(1024, 1024)
  directionalLight.shadow.camera.far = 15
  directionalLight.shadow.camera.top = 30
  directionalLight.shadow.camera.right = 30
  directionalLight.shadow.camera.bottom = -30
  directionalLight.shadow.camera.left = -30
  directionalLight.position.set(-8, 5, 0)
  scene.add(ambientLight, directionalLight)
}

/**
 * @description: 初始化画布
 * @return {*}
 */
function initRenderer() {
  renderer.shadowMap.enabled = true
  renderer.shadowMap.type =THREE.VSMShadowMap
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.toneMapping = THREE.ACESFilmicToneMapping
  renderer.setSize(deviceInfo.width, deviceInfo.height)
  renderer.setPixelRatio(deviceInfo.pixelRatio)
  document.querySelector('#app')?.appendChild(renderer.domElement)
}

function createBox() {
  const geometry = new THREE.BoxGeometry(1, 1, 1)
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
  const cube = new THREE.Mesh(geometry, material)
  camera.position.set(0, 3, 3)
  camera.lookAt(cube)
  scene.add(cube)
}

function render() {
  initCamera()
  initSceneOtherEffects()
  initRenderer()
  createBox()

	// 其实这个方法就是 requestAnimationFrame 的封装,当然你也可以使用 requestAnimationFrame 来实现
  renderer.setAnimationLoop(() => {
    renderer.render(scene, camera)
    orbitControls.update()
  })

  window.addEventListener('resize', resizeHandler)
}

function resizeHandler() {
  deviceInfo.width = window.innerWidth
  deviceInfo.height = window.innerHeight
  deviceInfo.pixelRatio = Math.min(window.devicePixelRatio, 2)

  camera.aspect = deviceInfo.width / deviceInfo.height
  camera.updateProjectionMatrix()
  renderer.setSize(deviceInfo.width, deviceInfo.height)
  renderer.setPixelRatio(deviceInfo.pixelRatio)
}

render()

代码简单实现了一个大体的框架,因为实现第三人称视觉环绕模型,比如原神,或者一些常见的第三方游戏都有这样功能,控制相机环绕人物旋转或者缩放,因此用到了 OrbitControls 轨道控制器,也可以从它的构造函数可以看出,传入的是一个 Camera 对象。 initSceneOtherEffects 方法就是初始化一些场景必要元素(灯光),resizeHandler 方法的处理,其实不算是 Threejs 的知识,而是属于 Canvas ,简单来说就是正确设置 Canvas 的 widthheight 属性,只是 Threejs 封装了一些方法用于修改而已。 createBox 方法是临时生成一个正方体用于代替人物模型,后面不需要。 运行代码后,可以看到一个绿色的正方体,以及允许在画布里进行拖动切换镜头和滚轮缩放。

基础框架效果图

接下来将提供均为伪代码,都是基于该基础框架添加的代码段

加载模型

复杂的模型一般都是引用外部模型文件(由建模师设计提供),很少说自己使用 Geometry 进行构建。

本案例的模型也是在网上找的。因为后续也使用到人物动画,所以我直接在 mixamo (推荐)这个网站下载一套了。怎么下载就不多说了,摸索一下就会。还有场景的模型,就自行上网找吧,大把大把的。

javascript
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'

let character // 保存人物模型
// 在基础框架 render 方法执行
function loadModals() {
  const fbxLoader = new FBXLoader()
  fbxLoader.load('/modals/girl.fbx', (player) => {
    player.scale.set(0.01, 0.01, 0.01) // 缩小100%,这个看情况
    camera.position.set(0, 1.6, 3) // 重新设置相机的位置
    orbitControls.target = player.position // 使轨道控制器围绕 player
		character = player // 后面需要用到
    scene.add(player)
  })

  const gltfLoader = new GLTFLoader()
  gltfLoader.load('/modals/meeting.glb', (gltf) => {
    scene.add(gltf.scene)
  })
}

相机的位置和缩放等这里数据不同模型可能设置不同。比如模型大小合适就没必要去缩小了。

加载模型主要通过 Loader (根据文件类型选择,常见的有 Gltf 等)进行加载。效果图如下 🖥️

加载模型效果图

目前已经看到已加入的会议场景和一个女生模型,虽然现在女生卡在桌子里,不着急,一步一步解决。

控制行走

分析场景:按下指定按钮,人物模型发生相对位移,那就是位置发生改变。因此得出需要监听按下和释放的动作,还有修改模型的位置,也就是 position

javascript
function render() {
	// ...省略部分代码
	window.addEventListener('keydown', onKeyDown)
  window.addEventListener('keyup', onKeyUp)
}

function onKeyDown(event) {
  const key = event.code
  if (key === 'KeyW') {
    velocity.set(0, 0, 1)
  } else if (key === 'KeyS') {
    velocity.set(0, 0, -1)
  } else if (key === 'KeyA') {
    velocity.set(-1, 0, 0)
  } else if (key === 'KeyD') {
    velocity.set(1, 0, 0)
  }

  character.position.add(velocity)
  camera.position.add(velocity)
  camera.target = character.position
}

function onKeyUp() {

}

这份代码这样一看,确实没什么问题,w 和 s 键控制人物 z 轴的位移,a 和 d 键控制人物 x 轴的位移。运行代码操作也符合预期,但这里有个小问题是人物的移动一卡一卡的,并不流畅,如果加入动画就更加明显了。🙊 这也是我踩的坑之一。

分析原因

键盘的监听是通过 keydown 事件触发的,画布的更新是通过 requestAnimationFrame 执行更新,而一卡一卡的原因就是他们不同频,打个比方 keydown 才执行几次, requestAnimationFrame 才执行一次,那么指定的间隔中(requestAnimationFrame 执行间隔跟屏幕刷新率有关), keydown 执行的次数不固定,那么就看起来一卡一卡的。

因此我们需要改造代码,将放在 requestAnimationFrame 进行修改人物位置。

javascript
// 记录按钮状态
const keyState = {
  "KeyW": false,
  "KeyS": false,
  "KeyA": false,
  "KeyD": false
}

/**
 * @description: 判断是否可允许按键
 * @param {*} key
 * @return {*}
 */
function isAllowKey(key) {
  return Object.keys(keyState).includes(key)
}

function onKeyDown(event) {
  if(isAllowKey(event.code)) {
    keyState[event.code] = true
  }
}

function onKeyUp(event) {
  if(isAllowKey(event.code)) { 
    keyState[event.code] = false
  }
}

function playerControl(delta) {
  if(Object.values(keyState).every(k => !k)) return

  for (let code in keyState) {
    if (!keyState[code]) continue
    switch (code) {
      case 'KeyW':
        velocity.z += 1
        break
      case 'KeyS':
        velocity.z -= 1
        break
      case 'KeyA':
        velocity.x -= 1
        break
      case 'KeyD':
        velocity.x += 1
        break
    }
  }

  // 同步修改相机和任务的位置
  character.position.addScaledVector(velocity.clone().normalize(), 4 * delta)
  camera.position.addScaledVector(velocity.clone().normalize(), 4 * delta)
  camera.target = character.position
  velocity.set(0, 0, 0)
}

function render() {
  // ...省略部分代码

  renderer.setAnimationLoop(() => {
    const deltaTime = Math.min(0.05, clock.getDelta())
    playerControl(deltaTime)
		// ...
  })
}

这段代码通过 keydownkeyup 事件记录按键状态,然后在 setAnimationLoop (即 requestAnimationFrame )时再修改人物位置。 playerControl 方法首先判断是否有按钮处于激活状态,无则直接跳过;往下就是遍历每一个按键状态,进行计算位移向量,遍历的原因主要是可能有多个按键处于激活状态,比如同时按 w 和 d 等。

4 * delta 的主要原因是控制不同刷新率的屏幕下单位时间内模型位移的距离是相同的。比如这里固定是 4 的话,那么 60HZ 和 120HZ 每秒分别执行 60次和 120次,屏幕刷新率越高的用户单位时间内位移更远显然是不合理的。因此我们需要乘以一个 delta ,它实际是clock.getDelta() 的执行结果,表示距离上次执行 getDelta 的间隔,比如 60HZ 大概每次的 delta 都是 0.016 左右,所以 4 * delta 表示 1 秒内人物走 4 个单位向量。

运行结果自行体验,会明显感觉比第一次的实现好了不少,更加流畅了。 到目前为止,如果相机是固定(即不能控制旋转),那么这代码再优化一下就差不多完成了(根据模型面向的轴的方向调整一下位移向量的正反就好),但目前功能是相机不固定的,就有点不太合理了,比如我把相机绕 y 轴旋转 90度,再按 w 向前走,人物相对相机(屏幕)是横着走,并不是预期的向前(相对相机),如图所示。

相机与人物关系

增强控制和碰撞检测

相对相机可控的情况下,人物的行走控制仍存在部分问题:

  1. 相机的照射的方向,应永远保持是人物的 w 方向。可以参考原神、塞尔达等。
  2. 人物正脸朝向问题,即人物需要跟随运动方向旋转。
  3. 碰撞问题。
  4. 添加人物跑步动画。

这小节主要解决的就是这几个问题。

注意

mesh.rotateY(rad)mesh.rotation.y = rad 两种不同的设置方式,实际效果不同。

mesh.rotateY(rad) 是相对旋转,它会基于当前旋转值进行叠加。(累积)

mesh.rotation.y = rad 是绝对旋转,它会直接将y轴的旋转角设置为指定的值,忽略之前的旋转状态。(绝对)

位移同理。

🤔 思路分析

  1. 既然是相机照射的方向为 w 方向,那么就需要知道相机的旋转角度,可以通过 orbitControls.getAzimuthalAngle() 获取旋转弧度。然后位移向量同步旋转相同弧度,即可得出最终的位移向量,恰好 vector3.applyAxisAngle(axis, angle) 可以实现。

人物移动解析图

如图所示,相机和人物的初始弧度为0 时的状态,因此每次往前(w方向)时,人物和相机的旋转角度相差 PI 弧度。需要注意的是 velocity 即位移向量,如果您定义的 w 激活时往 z 轴负方向移动的话,那么位移向量的旋转弧度与相机一致;如果是往 z 轴正方向移动的话,则与人物需要旋转弧度一致。(图示是w 激活时往 z 轴负方向移动)

  1. 由上图可知人物 0 弧度时的面向,以及相机与人物弧度的关系,因此推理即可得出其他几个方向的旋转弧度。

人物旋转解析图

  1. 碰撞主要利用射线碰撞原理,案例代码将会使用 八叉树 解决,实现的手段是首先建立场景的八叉树数据结构(即案例的会议室),然后使用 Capsule 胶囊体数据结构包裹人物,当人物移动时,同步移动 Capsule ,并且进行冲突检测 octree.capsuleIntersect(capsule) (原理和 raycaster.intersectObject() 一致),最后根据冲突的向量来调整最终位置即可。

  2. 动画的播放和暂停,可以使用 Threejs 提供的 AnimationMixer 来解决,相对比较简单。

动画控制的封装

javascript
class AnimationControl {
  constructor(path) {
    this.animations = {}
    this.fpxLoader = new FBXLoader()
    if (path) {
      this.fpxLoader.setPath(path)
    }
  }

  /**
   * @description: 加载动画(同名会发生覆盖)
   * @param {Record} options 键值对(动画名:动画地址)
   * @return {promise}
   */
  async load(options) {
    for (const key in options) {
      try {
        this.animations[key] = (
          await this.fpxLoader.loadAsync(options[key])
        ).animations[0]
      } catch (error) {
        console.error(key, error)
      }
    }
  }

  /**
   * @description: 是否存在某个动画
   * @param {string} name 动画名
   * @return {boolean}
   */
  isExistAnimation(name) {
    return !!this.animations[name]
  }

  /**
   * @description: 播放动画
   * @param {THREE} target 目标对象
   * @param {string|string[]} name 动画名字,允许传入多个动画
   * @param {boolean} loop 可选, 是否重播,默认 true
   * @return {THREE.AnimationMixer}
   */
  startAnimation(target, name, loop = true) {
    let animationNames = []
    if (typeof name === 'string') {
      animationNames.push(name)
    } else {
      animationNames = name
    }

    this.mixer = new THREE.AnimationMixer(target)
    animationNames.forEach((animName) => {
      if (!this.isExistAnimation(animName)) {
        console.warn(`Animation ${animName} is not exist`)
        return
      }

      const animationAction = this.mixer.clipAction(this.animations[animName])
      animationAction.setLoop(
        loop ? THREE.LoopRepeat : THREE.LoopOnce,
        Infinity,
      )
      animationAction.play().fadeIn(0.1)
    })

    return this.mixer
  }
}

其实最重要的是 startAnimation() 方法,通过 AnimationMixer 指定动画对象,clipAction 返回动画剪辑的 AnimationAction ,最后播放即可。load() 方法是加载动画文件,即 mixamo 下载的动画文件(Without Skin),其中返回的数据中 animations 字段保存的就是动画剪辑数组。

控制修改的代码较多!!!

javascript
import { Capsule } from 'three/examples/jsm/math/Capsule' // 胶囊体
import { Octree } from 'three/examples/jsm/math/Octree' // 八叉树

// ...省略部分代码
// 胶囊体参数
const capsuleParams = [
  new THREE.Vector3(0, 0.3, 0),
  new THREE.Vector3(0, 1.45, 0),
  0.3,
]
// 人物初始位置
const initialPosition = new THREE.Vector3(-6.8, 0, 11.4)
let playerOnFloor = false // 是否在地面上
const gravity = 2.5 // 重力
const octree = new Octree()
let playerCapsule // 胶囊体
let currentAction = 'idle' // 玩家当前动作
const capsuleDiffToPlayer = new THREE.Vector3(0, capsuleParams[0].y, 0)
const animationControl = new AnimationControl('/animations/')

// 修改较多!!!
function loadModals() {
  const fbxLoader = new FBXLoader()
  fbxLoader.load('/modals/girl.fbx', async (player) => {
    player.scale.set(0.01, 0.01, 0.01)
    playerCapsule = new Capsule(...capsuleParams) // 新建人物胶囊体
    playerCapsule.translate(initialPosition) // 修改胶囊体的位置
		// 玩家的位置也是胶囊体的位置
    player.position.copy(playerCapsule.start.clone().sub(capsuleDiffToPlayer))
    player.rotation.y = Math.PI / 2
    camera.position.set(-4, 1.6, 11.4)
    camera.updateProjectionMatrix()
    orbitControls.target = playerCapsule.end
    orbitControls.minDistance = 1
    orbitControls.maxDistance = 5
    orbitControls.maxPolarAngle = Math.PI / 2
    character = player

    // 加载动画
    await animationControl.load({
      idle: 'idle.fbx',
      running: 'running.fbx',
      jump: 'jump.fbx',
      sitting: 'sitting.fbx',
      waving: 'waving.fbx',
      dancing: 'dancing.fbx',
    })
    animationControl.startAnimation(player, currentAction)

    scene.add(player)
  })

  const gltfLoader = new GLTFLoader()
  gltfLoader.load('/modals/meeting.glb', (gltf) => {
		// 解析场景
    octree.fromGraphNode(gltf.scene)
    scene.add(gltf.scene)
  })
}

// 新增方法
/**
 * @description: 更新玩家状态
 * @param {number} delta
 * @param {Octree} octree
 * @return {void}
 */
function updatePlayer(delta, octree) {
  // 处理人物下落 start
	// 简单来说就说模仿重力,一直有一个向下的向量
  let damping = Math.exp(-4 * delta) - 1 // 模拟阻尼
  if (!playerOnFloor) {
    velocity.y -= gravity * delta
    damping *= 0.1
  }
  velocity.addScaledVector(velocity, damping)
	// 处理人物下落 end

  playerControl(delta) // 处理玩家操作
  playerCapsule.translate(velocity) // 修改胶囊体位置

  playerCollision(octree) // 碰撞检测

	// 处理相机位置和玩家位置 start
  const diff = new THREE.Vector3()
  diff.subVectors(camera.position, character.position) // 计算相机与人物的向量差
  character.position.copy(playerCapsule.start.clone().sub(capsuleDiffToPlayer))
  camera.position.copy(diff.add(character.position))
	// 处理相机位置 end
  orbitControls.target = playerCapsule.end
  velocity.set(0, 0, 0)

	// 根据按钮激活状态选择动画,任意一个按钮激活则是 running,否则 idle,并且需要判断是否与上一次动画是否一致,一致则无需播放(否则会很鬼畜)
  let nextAction
  if (
    keyState['KeyW'] ||
    keyState['KeyS'] ||
    keyState['KeyA'] ||
    keyState['KeyD']
  ) {
    nextAction = 'running'
  } else {
    nextAction = 'idle'
  }

  if (currentAction !== nextAction) {
    animationControl.startAnimation(character, nextAction)
    currentAction = nextAction
  }
}

// 此处大改!!!
/**
 * @description: 人物控制
 * @param {*} delta
 * @return {*}
 */
function playerControl(delta) {
  if (Object.values(keyState).every((k) => !k)) return
	
	// 这一块的原理其实就是判断按键,w则z轴-1, s则z轴+1,a则x轴-1,d则x轴+1,如果相反方向同时激活则抵消
  // playRotateRelativeToCamera 计算的是人物的旋转弧度,看不懂的话,可以改写成if w + d 命中旋转多少弧度,else if d + s 命中旋转多少弧度,else if ... 将所有可能进行判断计算
	// 知道原理的话可以在原先的 playerControl 扩展修改,不一定按这个方式写,这个是我优化后位置向量和旋转弧度一起计算。
  let forword = 0 // 0 代表没有前后 1表示前 -1表示后
  let side = 0
  const speed = delta * 4
  for (let code in keyState) {
    if (!keyState[code]) continue
    switch (code) {
      case 'KeyW':
        forword += 1
        break
      case 'KeyS':
        forword -= 1
        break
      case 'KeyA':
        side -= 1
        break
      case 'KeyD':
        side += 1
        break
    }
  }

  const controlRotationAngle = orbitControls.getAzimuthalAngle()
  // 计算旋转角度
  // remark: 因为相机与人物向前朝向永远差 PI 的弧度(可以理解为为了向前时相机总看到人物背面,因此旋转了PI弧度)
  const playRotateRelativeToCamera =
    ((forword <= 0 ? 0 : Math.PI * (side ? side : 1)) + (Math.PI * side) / 2) /
    (forword && side ? 2 : 1)
  // 旋转人物模型(修改人物面向)
  character.rotation.y = controlRotationAngle + playRotateRelativeToCamera

  if (forword) {
    velocity.z -= speed * forword
  }

  if (side) {
    velocity.x += speed * side
  }

  // 旋转位移向量
  velocity.applyAxisAngle(THREE.Object3D.DEFAULT_UP, controlRotationAngle)
}

/**
 * @description: 玩家碰撞检测
 * @param {*} octree
 * @return {*}
 */
function playerCollision(octree) {
	// 检测碰撞,判断场景和玩家是否有射线相交
  const result = octree.capsuleIntersect(playerCapsule)
  playerOnFloor = false
  if (result) {
    playerOnFloor = result.normal.y >= 0
    if (!playerOnFloor) {
      // normal 是碰撞的单位方向向量
      velocity.addScaledVector(result.normal, -result.normal.dot(velocity))
    }
    playerCapsule.translate(result.normal.multiplyScalar(result.depth))
  }
}

function render() {
	// ... 省略部分代码

  renderer.setAnimationLoop(() => {
    const deltaTime = Math.min(0.05, clock.getDelta())
		// 移除 playerControl(deltaTime)
		// -----添加部分
    if (character && octree) {
      updatePlayer(deltaTime, octree)
      animationControl.mixer?.update(deltaTime)
    }
		// -----
    // ...
  })
}

动画播放主要看 AnimationControl 类,碰撞相关处理在 playerCollision 方法,玩家控制处理和旋转处理在 playerControl 方法,而 updatePlayer 方法只是将最终结果进行赋值到模型上而已。

🏃🏃🏃 运行效果如下

第三人称漫游效果图

完整代码(有处理封装过,可能和上文贴的代码略有不同,但原理一致)

Released under the MIT License.