scene walk
foreword
Get into the habit of writing together! This is the first day of my participation in the "Nuggets Daily New Plan · April Update Challenge", click to view the details of the event .
I have been too busy recently, and I have been interrupted for a while. Recently, I have been busy with multiple project teams. Participated in the secondary encapsulation of the company's threejs engine, and gained a lot. This article mainly introduces the roaming solution for realizing first-person and third-person switching and collision detection in 3D scenes.
first effect
The gif is a bit big, please wait a moment~
dissect
graph LR
模型生成碰撞面 --> 添加机器人 --> 操作移动事件 --> 移动及相机位置及碰撞监测更新
需要实现的功能如下:
- 加载模型生成碰撞面
- 添加模型机器人及动画
- 操纵移动以及视角跟随
- 当然还需要相机及灯光等这里就不做一一赘述(相对基础)
简单说:通过生成碰撞面进行碰撞监测,通过WASD实现物体移动以及实时更新相机位置及角度
复制代码
Create collision surfaces
graph LR
遍历模型 --> THREE.Box3创建包围盒 --> 包围盒通过插件生成碰撞面
依赖 three-glow-mesh 插件
原理通过模型拆解计算生成碰撞面,个人理解是通过拆解模型,每个几何体的顶点去生成一个正方体碰撞面模型,用来计算
碰撞面。市面上也有其他方案计算碰撞面,有通过直接计算模型生成碰撞面,但是如果模型一旦太大,计算会导致内存崩溃。
可以参考开源项目 https://gkjohnson.github.io/three-mesh-bvh/example/bundle/characterMovement.html
复制代码
//核心代码
通过深度遍历拆解模型计算生成碰撞面,想深入了解可以看作者的源码,这块代码我做了一点修改,也是一知半解
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)
}
复制代码
Load robot models and animations
这里只做简单的赘述,因为这块相对比较基础,贴出核心代码
这边有个特殊点,由于视角问题,通过机器人跟随隐藏几何体的位置便于调整机器人的视角高度,事实上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)
})
}
复制代码
Action event
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
}
})
}
复制代码
Model camera position update
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)
}
}
复制代码
Click Floor Displacement
通过二维坐标转化三维坐标以及自定义着色器实现功能
鼠标位置转换三维位置,修改着色器位置,控制着色器显隐以及着色器动画实现光圈~
复制代码
//着色器
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控制模型移动,动画过渡效果以及碰撞监测、相机位置调整、向量计算、相机初始化等~
复制代码
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)
}
}
}
复制代码
Epilogue
由于时间原因,这篇文章参考的是一个开源项目作出特定的修改,开源链接已经放在文章内,很多细节的地方没办法打上注释,一部分因为自己也不是特别理解,一部分是因为时间比较赶。
由于代码太长无法贴出,细节可以参考开源demo哈~
复制代码