threejs双人称场景漫游

15,506 阅读8分钟

场景漫游

前言

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

最近太忙了,断更了一段时间,最近一直忙碌于多个项目组。参与了公司threejs引擎的二次封装,收获颇多,这篇文章主要介绍在3d场景中实现第一人称与第三人称切换以及碰撞监测的漫游方案。

人称视角的场景漫游主要需要解决两个问题,人物在场景中的移动和碰撞检测。移动与碰撞功能是所有三维场景首先需要解决的基本问题,今天我们就通过最基本的threejs来完成第一人称视角的场景漫游功能

three.js

轻量级开源的JavaScript 3D引擎,传播最广泛的WebGL三维引擎。对WebGL底层的东西进行了封装,让非计算机图形学领域的人也可以实现三维世界 WebGL从应用的角度看,在不同领域差别也比较大。对机械而言更多是侧重于建模,工业设计除了建模外,还要进行仿真渲染,游戏的角色模型更侧重于柔性的人 体、动物体。WebGL的引擎比较多,将来会更多,不可能都要掌握背会,比如你的特长是渲染模型,对于你而言不同的框架,只是具体代码不同而已,但是灯光、环境、贴图的概念是相同的,代码的思想是相同的。

在数据可视化领域利用webgl来创建三维场景或VR已经越来越普遍,各种开发框架也应运而生。今天我们就通过最基本的threejs来完成第一人称及第三人称视角的场景漫游功能。如果你是一位threejs的初学者或正打算入门,我强烈推荐你仔细阅读本文并在我的代码基础之上继续深入学习。

先上效果

gif图有点大稍微等一下哈~

1.gif

剖析

graph LR
模型生成碰撞面 --> 添加机器人 --> 操作移动事件 --> 移动及相机位置及碰撞监测更新
需要实现的功能如下:
     - 加载模型生成碰撞面
     - 添加模型机器人及动画
     - 操纵移动以及视角跟随
     - 当然还需要相机及灯光等这里就不做一一赘述(相对基础)
简单说:通过生成碰撞面进行碰撞监测,通过WASD实现物体移动以及实时更新相机位置及角度

创建碰撞面

graph LR
遍历模型 --> THREE.Box3创建包围盒 --> 包围盒通过插件生成碰撞面

依赖 three-glow-mesh 插件 原理通过模型拆解计算生成碰撞面,个人理解是通过拆解模型,每个几何体的顶点去生成一个正方体碰撞面模型,用来计算 碰撞面。市面上也有其他方案计算碰撞面,有通过直接计算模型生成碰撞面,但是如果模型一旦太大,计算会导致内存崩溃。 可以参考开源项目 gkjohnson.github.io/three-mesh-… Snipaste_2022-04-01_13-16-43.png

这一块所需具备 模型深度遍历 geometry方面知识

通过深度遍历拆解模型计算生成碰撞面,想深入了解可以看作者的源码,这块代码我做了一点修改,也是一知半解。大概理解就是通过遍历拾取geometry生成面片,具体可以查看MeshBVH的源码

//核心代码
loadColliderEnvironment( scene, camera, model) {//传入场景及相机及模型
  const that = this
  const gltfScene = model
  new THREE.Box3().setFromObject(model)
  gltfScene.updateMatrixWorld(true)
  that.model=model
  // visual geometry setup
  const toMerge = {}
  gltfScene.traverse(c => {
    if (c.isMesh && c.material.color !== undefined) {
      const hex = c.material.color.getHex()
      toMerge[hex] = toMerge[hex] || []
      toMerge[hex].push(c)
    }
  })

  that.environment = new THREE.Group()
  for (const hex in toMerge) {
    const arr = toMerge[hex]
    const visualGeometries = []
    arr.forEach(mesh => {
      if (mesh.material.emissive && mesh.material.emissive.r !== 0) {
        that.environment.attach(mesh)
      } else {
        const geom = mesh.geometry.clone()
        geom.applyMatrix4(mesh.matrixWorld)
        visualGeometries.push(geom)
      }
    })

    if (visualGeometries.length) {
      const newGeom = BufferGeometryUtils.mergeBufferGeometries(visualGeometries)
      const newMesh = new THREE.Mesh(newGeom, new THREE.MeshStandardMaterial({
        color: parseInt(hex),
        shadowSide: 2
      }))
      newMesh.castShadow = true
      newMesh.receiveShadow = true
      newMesh.material.shadowSide = 2
      newMesh.name = 'mool'
      that.environment.add(newMesh)
    }
  }

  // collect all geometries to merge
  const geometries = []
  that.environment.updateMatrixWorld(true)
  that.environment.traverse(c => {
    if (c.geometry) {
      const cloned = c.geometry.clone()
      cloned.applyMatrix4(c.matrixWorld)
      for (const key in cloned.attributes) {
        if (key !== 'position') {
          cloned.deleteAttribute(key)
        }
      }

      geometries.push(cloned)
    }
  })

  // create the merged geometry
  const mergedGeometry = BufferGeometryUtils.mergeBufferGeometries(geometries, false)
  mergedGeometry.boundsTree = new MeshBVH(mergedGeometry, {lazyGeneration: false})

  that.collider = new THREE.Mesh(mergedGeometry)
  that.collider.material.wireframe = true
  that.collider.material.opacity = 0.5
  that.collider.material.transparent = true
  that.visualizer = new MeshBVHVisualizer(that.collider, that.params.visualizeDepth)
  that.visualizer.layers.set(that.currentlayers)
  that.collider.layers.set(that.currentlayers)
  scene.add(that.visualizer)
  scene.add(that.collider)
  scene.add(that.environment)
}

加载机器人模型及动画

这一块所需具备 相机相关知识 模型相关知识 模型动画 object3D相关属性

这里只做简单的赘述,因为这块相对比较基础,贴出核心代码 这边有个特殊点,由于视角问题,通过机器人跟随隐藏几何体的位置便于调整机器人的视角高度,事实上WASD以及跳跃 操作的是几何圆柱体,只是这里我将圆柱体隐藏了起来,由于之前的测试,如果没有圆柱体这个参照物,无法正确调整机 器人的视角高度,以至于视角相对矮小~

这里比较基础,不做过多注释,看不懂可以看我第一篇动画文章
loadplayer(scene, camera) {
  const that = this
  // character 人物模型参考几何体
  that.player = new THREE.Mesh(
    new RoundedBoxGeometry(0.5, 1.7, 0.5, 10, 0.5),
    new THREE.MeshStandardMaterial()
  )
  that.player.geometry.translate(0, -0.5, 0)
  that.player.capsuleInfo = {
    radius: 0.5,
    segment: new THREE.Line3(new THREE.Vector3(), new THREE.Vector3(0, -1.0, 0.0))
  }
  that.player.name = 'player'
  that.player.castShadow = true
  that.player.receiveShadow = true
  that.player.material.shadowSide = 2
  that.player.visible = false
  scene.add(that.player)
  const loader = new GLTFLoader()
  loader.load('/static/public/RobotExpressive.glb', (gltf) => {
    gltf.scene.scale.set(0.3, 0.3, 0.3)
    that.robot = gltf.scene
    that.robot.capsuleInfo = {
      radius: 0.5,
      segment: new THREE.Line3(new THREE.Vector3(), new THREE.Vector3(0, -1, 0))
    }
    that.robot.castShadow = true
    that.robot.receiveShadow = true
    that.robot.visible = true
    that.robot.traverse(c => {
      c.layers.set(that.currentlayers)
    })
    const animations = gltf.animations //动画
    that.mixer = new THREE.AnimationMixer(gltf.scene)
    var action = that.mixer.clipAction(animations[6])
    action.play()
    scene.add(that.robot)
    that.reset(camera)
  })
}

操作事件

graph LR
绑定键盘事件 --> 控制WASD开关 --> 间接影响render中的移动开关

这边包括WASD移动以及跳跃和人称切换。通过事件绑定的形式,操作标识开关,操作对应方向的坐标系移动,这里 只是事件相关,重点在于render函数中~

this.params = { // gui配置对对象 这是初始化中为了配置gui的对象
  firstPerson: false,
  displayCollider: false,
  displayBVH: false,
  visualizeDepth: 10,
  gravity: -30,
  playerSpeed: 5,
  physicsSteps: 5,
  reset: that.reset
}
windowEvent(camera, renderer) {
  const that = this
  window.addEventListener('resize', function () {
    camera.aspect = window.innerWidth / window.innerHeight
    camera.updateProjectionMatrix()

    renderer.setSize(window.innerWidth, window.innerHeight)
  }, false)

  window.addEventListener('keydown', function (e) {
    switch (e.code) {
      case 'KeyW':
        that.fwdPressed = true
        break
      case 'KeyS':
        that.bkdPressed = true
        break
      case 'KeyD':
        that.rgtPressed = true
        break
      case 'KeyA':
        that.lftPressed = true
        break
      case 'Space':
        if (that.playerIsOnGround) {
          that.playerVelocity.y = 10.0
        }
        break
      case 'KeyV':
        that.params.firstPerson = !that.params.firstPerson
        if (!that.params.firstPerson) { //人称切换
          camera
            .position
            .sub(that.controls.target)
            .normalize()
            .multiplyScalar(10)
            .add(that.controls.target)
          that.robot.visible = true
        } else {
          that.robot.visible = false
        }
        break
    }
  })

  window.addEventListener('keyup', function (e) {
    switch (e.code) {
      case 'KeyW':
        that.fwdPressed = false
        break
      case 'KeyS':
        that.bkdPressed = false
        break
      case 'KeyD':
        that.rgtPressed = false
        break
      case 'KeyA':
        that.lftPressed = false
        break
    }
  })
}

模型相机位置更新

graph LR
实时计算更新圆柱体的世界坐标 --> 检测碰撞 --> 同步到模型以及相机上

除了碰撞监测,所谓漫游最重要的就是移动和相机跟随 这里要理解一点,除了物体自身的坐标系还存在一个世界坐标系,我们修改物体的同时需要更新其在世界坐标系中的顶点坐标位置。 通过WASD开关来控制模型移动,通过向量的计算以及模型碰撞的监测,调整模型的位置以及相机的位置。 reset主要是从高处掉落后是否碰撞到地面,用于不知道地面的高度下,监测地面碰撞面是否形成与是否需要重新下落~

初始化的一些参数
const upVector = new THREE.Vector3(0, 1, 0)
const tempVector = new THREE.Vector3()
const tempVector2 = new THREE.Vector3()
const tempBox = new THREE.Box3()
const tempMat = new THREE.Matrix4()
const tempSegment = new THREE.Line3()
updatePlayer(delta, params, fwdPressed, tempVector, upVector, bkdPressed, lftPressed, rgtPressed, tempBox, tempMat, tempSegment, tempVector2, camera) {
  const that = this
  that.playerVelocity.y += that.playerIsOnGround ? 0 : delta * params.gravity
  that.player.position.addScaledVector(that.playerVelocity, delta)
  // move the player
  const angle = that.controls.getAzimuthalAngle()
  //WASD
  if (fwdPressed) {
    tempVector.set(0, 0, -1).applyAxisAngle(upVector, angle)
    that.player.position.addScaledVector(tempVector, params.playerSpeed * delta)
  }

  if (bkdPressed) {
    tempVector.set(0, 0, 1).applyAxisAngle(upVector, angle)
    that.player.position.addScaledVector(tempVector, params.playerSpeed * delta)
  }

  if (lftPressed) {
    tempVector.set(-1, 0, 0).applyAxisAngle(upVector, angle)
    that.player.position.addScaledVector(tempVector, params.playerSpeed * delta)
  }

  if (rgtPressed) {
    tempVector.set(1, 0, 0).applyAxisAngle(upVector, angle)
    that.player.position.addScaledVector(tempVector, params.playerSpeed * delta)
  }
  //更新模型世界坐标
  that.player.updateMatrixWorld()

  // adjust player position based on collisions
  const capsuleInfo = that.player.capsuleInfo
  tempBox.makeEmpty()
  tempMat.copy(that.collider.matrixWorld).invert()
  tempSegment.copy(capsuleInfo.segment)

  // get the position of the capsule in the local space of the collider
  tempSegment.start.applyMatrix4(that.player.matrixWorld).applyMatrix4(tempMat)
  tempSegment.end.applyMatrix4(that.player.matrixWorld).applyMatrix4(tempMat)

  // get the axis aligned bounding box of the capsule
  tempBox.expandByPoint(tempSegment.start)
  tempBox.expandByPoint(tempSegment.end)

  tempBox.min.addScalar(-capsuleInfo.radius)
  tempBox.max.addScalar(capsuleInfo.radius)

  that.collider.geometry.boundsTree.shapecast({

    intersectsBounds: box => box.intersectsBox(tempBox),

    intersectsTriangle: tri => {
      // check if the triangle is intersecting the capsule and adjust the
      // capsule position if it is.
      const triPoint = tempVector
      const capsulePoint = tempVector2

      const distance = tri.closestPointToSegment(tempSegment, triPoint, capsulePoint)
      if (distance < capsuleInfo.radius) {
        const depth = capsuleInfo.radius - distance
        const direction = capsulePoint.sub(triPoint).normalize()

        tempSegment.start.addScaledVector(direction, depth)
        tempSegment.end.addScaledVector(direction, depth)
      }
    }

  })

  // get the adjusted position of the capsule collider in world space after checking
  // triangle collisions and moving it. capsuleInfo.segment.start is assumed to be
  // the origin of the player model.
  const newPosition = tempVector
  newPosition.copy(tempSegment.start).applyMatrix4(that.collider.matrixWorld)

  // check how much the collider was moved
  const deltaVector = tempVector2
  deltaVector.subVectors(newPosition, that.player.position)
  // if the player was primarily adjusted vertically we assume it's on something we should consider ground
  that.playerIsOnGround = deltaVector.y > Math.abs(delta * that.playerVelocity.y * 0.25)

  const offset = Math.max(0.0, deltaVector.length() - 1e-5)
  deltaVector.normalize().multiplyScalar(offset)

  // adjust the player model
  that.player.position.add(deltaVector)
  if (!that.playerIsOnGround) {
    deltaVector.normalize()
    that.playerVelocity.addScaledVector(deltaVector, -deltaVector.dot(that.playerVelocity))
  } else {
    that.playerVelocity.set(0, 0, 0)
  }
  // adjust the camera
  camera.position.sub(that.controls.target)
  that.controls.target.copy(that.player.position)
  camera.position.add(that.player.position)
  that.player.rotation.y = that.controls.getAzimuthalAngle() + 3
  if (that.robot) {
    that.robot.rotation.y = that.controls.getAzimuthalAngle() + 3
    that.robot.position.set(that.player.position.clone().x, that.player.position.clone().y, that.player.position.clone().z)
    that.robot.position.y -= 1.5
  }
  // if the player has fallen too far below the level reset their position to the start
  if (that.player.position.y < -25) {
    that.reset(camera)
  }
}

点击地板位移

通过二维坐标转化三维坐标以及自定义着色器实现功能 鼠标位置转换三维位置,修改着色器位置,控制着色器显隐以及着色器动画实现光圈~ 20220401135427_看图王.gif

//着色器
scatterCircle(r, init, ring, color, speed) {
   var uniform = {
     u_color: {value: color},
     u_r: {value: init},
     u_ring: {
       value: ring
     }
   }

   var vs = `
 varying vec3 vPosition;
 void main(){
  vPosition=position;
  gl_Position  = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
 }
`
   var fs = `
 varying vec3 vPosition;
 uniform vec3 u_color;
 uniform float u_r;
 uniform float u_ring;

 void main(){
  float pct=distance(vec2(vPosition.x,vPosition.y),vec2(0.0));
  if(pct>u_r || pct<(u_r-u_ring)){
   gl_FragColor = vec4(1.0,0.0,0.0,0);
  }else{
   float dis=(pct-(u_r-u_ring))/(u_r-u_ring);
   gl_FragColor = vec4(u_color,dis);
  }
 }
`
   const geometry = new THREE.CircleGeometry(r, 120)
   var material = new THREE.ShaderMaterial({
     vertexShader: vs,
     fragmentShader: fs,
     side: THREE.DoubleSide,
     uniforms: uniform,
     transparent: true,
     depthWrite: false
   })
   const circle = new THREE.Mesh(geometry, material)
   circle.layers.set(this.currentlayers)

   function render() {
     uniform.u_r.value += speed || 0.1
     if (uniform.u_r.value >= r) {
       uniform.u_r.value = init
     }
     requestAnimationFrame(render)
   }

   render()
   return circle
 }

通过鼠标拾取二维坐标系转换为三维坐标系并计算光圈应该出去的位置以及高度,添加模型移动的过渡动画效果以及光圈的动画效果~

//点击事件
clickMobile(camera, scene) {
  const raycaster = new THREE.Raycaster()
  const mouse = new THREE.Vector2()

  const that = this
  document.addEventListener('dblclick', function (ev) {
    mouse.x = (ev.clientX / window.innerWidth) * 2 - 1
    mouse.y = -(ev.clientY / window.innerHeight) * 2 + 1
    // 这里我们只检测模型的选中情况
    raycaster.setFromCamera(mouse, camera)
    const intersects = raycaster.intersectObjects(scene.children, true)
    if (intersects.length > 0) {
      var selected
      let ok = false
      intersects.map(child => {
        if (child.object.name === 'mool' && !ok) {
          selected = child// 取第一个物体
          ok = true
        }
      })
      if (selected) {
        that.walking = true
        clearTimeout(that.timer)
        if (!that.circle) {
          that.circle = that.scatterCircle(1, 0.1, 0.3, new THREE.Vector3(0, 1, 1), 0.1)
          scene.add(that.circle)
        }
        const d1 = that.player.position.clone()
        const d2 = new THREE.Vector3(selected.point.x, that.player.position.y, selected.point.z)
        const distance = d1.distanceTo(d2)
        that.circle.position.set(selected.point.x, 2.5, selected.point.z)
        that.circle.rotation.x = Math.PI / 2
        that.setTweens(that.player.position, {
          x: selected.point.x,
          y: that.player.position.y,
          z: selected.point.z
        }, distance * 222)
        that.timer = setTimeout(() => {
          that.walking = false
          that.circle.visible = false
        }, distance * 222)
        that.circle.visible = true
      }
    }
  }, false)
}

render

render函数主要更新场景中的一些动画位置 通过WASD控制模型移动,动画过渡效果以及碰撞监测、相机位置调整、向量计算、相机初始化等~

主要思路:通过WASD以及跳跃开关,开启模型动画,计算移动后的朝向以及位置同步到模型和相机上

function render() {
  // stats.update()
  that.timeIndex = requestAnimationFrame(render)
  TWEEN.update()
  const delta = Math.min(clock.getDelta(), 0.1)
  if (that.mixer && (that.rgtPressed || that.lftPressed || that.bkdPressed || that.fwdPressed || that.walking) && !that.params.firstPerson) {
    that.mixer.update(delta)
  }
  if (that.params.firstPerson) {
    that.controls.maxPolarAngle = Math.PI / 2
    that.controls.minDistance = 1e-4
    that.controls.maxDistance = 1e-4
  } else {
    that.controls.maxPolarAngle = Math.PI / 2
    that.controls.minDistance = 10
    that.controls.maxDistance = 20
  }

  if (that.collider && that.player) {
    that.collider.visible = that.params.displayCollider
    that.visualizer.visible = that.params.displayBVH

    const physicsSteps = that.params.physicsSteps
    for (let i = 0; i < physicsSteps; i++) {
      that.updatePlayer(delta / physicsSteps, that.params, that.fwdPressed, tempVector, upVector, that.bkdPressed, that.lftPressed, that.rgtPressed, tempBox, tempMat, tempSegment, tempVector2, camera)
    }
  }
}

结语

由于时间原因,这篇文章参考的是一个开源项目作出特定的修改,开源链接已经放在文章内,很多细节的地方没办法打上注释,一部分因为自己也不是特别理解,一部分是因为时间比较赶。 由于代码太长无法贴出,细节可以参考开源demo哈~