本文详细讲解在three.js中实现镜头沿折线路径移动的效果。通过双相机机制解决OrbitControls绑定后的朝向与位置控制难题,利用辅助点处理直角转弯处的曲线化问题,并修正因坐标舍入和最近距离限制导致的镜头抖动与后退现象。对比了直接推进与Tween动画两种方案,提供了平滑且可控的第一视角移动实现思路。
实现目标效果
定义一条折线路径,使摄像机沿该路径前进,模拟第一人称视角沿路径行走的体验。
技术实现思路
核心逻辑为绘制折线路径,将摄像机位置动态绑定至路径当前点,并设定摄像机朝向路径前方。
关键技术难点
折线渲染为曲线的问题
直接使用THREE.Line绘制包含转折点的折线时,渲染结果往往呈现为曲线而非直线。
解决方案说明
- 1.1、以转折点为分割,分段绘制直线,确保上一线段终点即为下一线段起点。
- 1.2、在转折点处增加辅助点,构建极短弧线以修正折线显示效果。
摄像机朝向控制失效
当摄像机由OrbitControls绑定时,直接修改camera.lookAt或rotation属性无效。
问题解析
需通过设置controls.target来调整相机观察方向,直接设置lookAt无效。
摄像机位置绑定偏移
对于由controls绑定的摄像机,动态修改camera.position常出现位置错位。
问题解析
此问题曾导致长时间调试失败,即使参考前例控制controls.object.position亦无效。
实为配置问题:因设置了相机距原点的最小距离,转弯时因距离过近触发回退机制,导致镜头后退碰撞。
// 设置相机距离原点的最近距离 即可控制放大限值
// controls.minDistance = 4
// 设置相机距离原点的最远距离 即可控制缩小限值
controls.maxDistance = 40
摄像机抖动现象
怀疑因位置与朝向坐标四舍五入导致数值微小波动,引发画面上下左右抖动。
排查过程
初步尝试放大场景、拉长折线及拉远相机,均未解决问题。
最终定位在animate()循环中设置相机位置时,y坐标额外增加了0.01。
controls.object.position.set(testList[testIndex].x, testList[testIndex].y + 0.01, testList[testIndex].z)
相机位置坐标与朝向坐标不在同一平面引发抖动,移除+0.01后恢复正常。
controls.object.position.set(testList[testIndex].x, testList[testIndex].y, testList[testIndex].z)
最终解决方案
采用双相机机制:先通过辅助相机cameraTest确定移动路径与转向,再切换至主相机camera。
通用代码配置
// 外层相机,原始相机
let camera = null
// 内层相机和相机辅助线
let cameraTest = null
let cameraHelper = null
// 控制器
let controls = null
// 折线点的集合和索引
let testList = []
let testIndex = 0
initCamera () {
// 原始相机
camera = new THREE.PerspectiveCamera(45, div3D.clientWidth / div3D.clientHeight, 0.1, 1000)
camera.position.set(16, 6, 10)
// scene.add(camera)
// camera.lookAt(new THREE.Vector3(0, 0, 0))
// 设置第二个相机
cameraTest = new THREE.PerspectiveCamera(45, div3D.clientWidth / div3D.clientHeight, 0.1, 1000)
cameraTest.position.set(0, 0.6, 0)
cameraTest.lookAt(new THREE.Vector3(0, 0, 0))
cameraTest.rotation.x = 0
// 照相机帮助线
cameraHelper = new THREE.CameraHelper(cameraTest)
scene.add(cameraTest)
scene.add(cameraHelper)
}
// 初始化控制器
initControls () {
controls = new OrbitControls(camera, renderer.domElement)
}
方案一:沿路径推进
inspectCurveList () {
let curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(2.9, 0.6, 7),
new THREE.Vector3(2.9, 0.6, 1.6),
new THREE.Vector3(2.89, 0.6, 1.6), // 用于直角转折
new THREE.Vector3(2.2, 0.6, 1.6),
new THREE.Vector3(2.2, 0.6, 1.59), // 用于直角转折
new THREE.Vector3(2.2, 0.6, -5),
new THREE.Vector3(2.21, 0.6, -5), // 用于直角转折
new THREE.Vector3(8, 0.6, -5),
new THREE.Vector3(8, 0.6, -5.01), // 用于直角转折
new THREE.Vector3(8, 0.6, -17),
new THREE.Vector3(7.99, 0.6, -17), // 用于直角转折
new THREE.Vector3(-1, 0.6, -17),
// new THREE.Vector3(-2, 0.6, -17.01), // 用于直角转折
new THREE.Vector3(-3, 0.6, -20.4),
new THREE.Vector3(-2, 0.6, 5)
])
let geometry = new THREE.Geometry()
let gap = 1000
for (let i = 0; i < gap; i++) {
let index = i / gap
let point = curve.getPointAt(index)
let position = point.clone()
curveList.push(position)
geometry.vertices.push(position)
}
// geometry.vertices = curve.getPoints(500)
// curveList = geometry.vertices
// let material = new THREE.LineBasicMaterial({color: 0x3cf0fa})
// let line = new THREE.Line(geometry, material) // 连成线
// line.name = 'switchInspectLine'
// scene.add(line) // 加入到场景中
}
// 模仿管道的镜头推进
if (curveList.length !== 0) {
if (curveIndex < curveList.length - 20) {
// 推进里层相机
/* cameraTest.position.set(curveList[curveIndex].x, curveList[curveIndex].y, curveList[curveIndex].z)
controls = new OrbitControls(cameraTest, labelRenderer.domElement) */
// 推进外层相机
// camera.position.set(curveList[curveIndex].x, curveList[curveIndex].y + 1, curveList[curveIndex].z)
controls.object.position.set(curveList[curveIndex].x, curveList[curveIndex].y, curveList[curveIndex].z)
controls.target = curveList[curveIndex + 20]
// controls.target = new THREE.Vector3(curveList[curveIndex + 2].x, curveList[curveIndex + 2].y, curveList[curveIndex + 2].z)
curveIndex += 1
} else {
curveList = []
curveIndex = 0
this.inspectSwitch = false
this.addRoomLabel()
this.removeLabel()
// 移除场景中的线
// let removeLine = scene.getObjectByName('switchInspectLine')
// if (removeLine !== undefined) {
// scene.remove(removeLine)
// }
// 还原镜头位置
this.animateCamera({x: 16, y: 6, z: 10}, {x: 0, y: 0, z: 0})
}
}
方案二:利用tween动画
inspectTween () {
let wayPoints = [
{
point: {x: 2.9, y: 0.6, z: 1.6},
camera: {x: 2.9, y: 0.6, z: 7},
time: 3000
},
{
point: {x: 2.2, y: 0.6, z: 1.6},
camera: {x: 2.9, y: 0.6, z: 1.6},
time: 5000
},
{
point: {x: 2.2, y: 0.6, z: -5},
camera: {x: 2.2, y: 0.6, z: 1.6},
time: 2000
},
{
point: {x: 8, y: 0.6, z: -5},
camera: {x: 2.2, y: 0.6, z: -5},
time: 6000
},
{
point: {x: 8, y: 0.6, z: -17},
camera: {x: 8, y: 0.6, z: -5},
time: 3000
},
{
point: {x: -2, y: 0.6, z: -17},
camera: {x: 8, y: 0.6, z: -17},
time: 3000
},
{
point: {x: -2, y: 0.6, z: -20.4},
camera: {x: -2, y: 0.6, z: -17},
time: 3000
},
{
point: {x: -2, y: 0.6, z: 5},
camera: {x: -3, y: 0.6, z: -17},
time: 3000
},
// {
// point: {x: -2, y: 0.6, z: 5},
// camera: {x: -2, y: 0.6, z: -20.4}
// },
{
point: {x: 0, y: 0, z: 0},
camera: {x: -2, y: 0.6, z: 5},
time: 3000
}
]
this.animateInspect(wayPoints, 0)
}
animateInspect (point, k) {
let self = this
let time = 3000
if (point[k].time) {
time = point[k].time
}
let count = point.length
let target = point[k].point
let position = point[k].camera
let tween = new TWEEN.Tween({
px: camera.position.x, // 起始相机位置x
py: camera.position.y, // 起始相机位置y
pz: camera.position.z, // 起始相机位置z
tx: controls.target.x, // 控制点的中心点x 起始目标位置x
ty: controls.target.y, // 控制点的中心点y 起始目标位置y
tz: controls.target.z // 控制点的中心点z 起始目标位置z
})
tween.to({
px: position.x,
py: position.y,
pz: position.z,
tx: target.x,
ty: target.y,
tz: target.z
}, time)
tween.onUpdate(function () {
camera.position.x = this.px
camera.position.y = this.py
camera.position.z = this.pz
controls.target.x = this.tx
controls.target.y = this.ty
controls.target.z = this.tz
// controls.update()
})
tween.onComplete(function () {
// controls.enabled = true
if (self.inspectSwitch && k < count - 1) {
self.animateInspect(point, k + 1)
} else {
self.inspectSwitch = false
self.addRoomLabel()
self.removeLabel()
}
// callBack && callBack()
})
// tween.easing(TWEEN.Easing.Cubic.InOut)
tween.start()
},
方案对比分析
- 方案一控制简单,但运动轨迹不够平滑。
- 方案二控制复杂,需指定当前点与目标点,切换平滑但路径控制不严格。
偏好方案二,只要选定线路控制点,动画效果更佳且易控制各段动画时长。
其他尝试方法
记录过程中使用的其他方法,仅供参考。
方法一:绘制单条折线+animate推进
// 获取折线点数组
testInspect () {
// 描折线点,为了能使一条折线能直角转弯,特添加“用于直角转折”的辅助点,尝试将所有标为“用于直角转折”的点去掉,折线马上变曲线。
let curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(2.9, 0.6, 7),
new THREE.Vector3(2.9, 0.6, 1.6),
new THREE.Vector3(2.89, 0.6, 1.6), // 用于直角转折
new THREE.Vector3(2.2, 0.6, 1.6),
new THREE.Vector3(2.2, 0.6, 1.59), // 用于直角转折
new THREE.Vector3(2.2, 0.6, -5),
new THREE.Vector3(2.21, 0.6, -5), // 用于直角转折
new THREE.Vector3(8, 0.6, -5),
new THREE.Vector3(8, 0.6, -5.01), // 用于直角转折
new THREE.Vector3(8, 0.6, -17),
new THREE.Vector3(7.99, 0.6, -17), // 用于直角转折
new THREE.Vector3(-2, 0.6, -17),
new THREE.Vector3(-2, 0.6, -17.01), // 用于直角转折
new THREE.Vector3(-2, 0.6, -20.4),
new THREE.Vector3(-2, 0.6, 5),
])
let material = new THREE.LineBasicMaterial({color: 0x3cf0fa})
let geometry = new THREE.Geometry()
geometry.vertices = curve.getPoints(1500)
let line = new THREE.Line(geometry, material) // 连成线
scene.add(line) // 加入到场景中
testList = geometry.vertices
}
// 场景动画-推进相机
animate () {
// 模仿管道的镜头推进
if (testList.length !== 0) {
if (testIndex < testList.length - 2) {
// 推进里层相机
// cameraTest.position.set(testList[testIndex].x, testList[testIndex].y, testList[testIndex].z)
// controls = new OrbitControls(cameraTest, labelRenderer.domElement)
// controls.target = new THREE.Vector3(testList[testIndex + 2].x, testList[testIndex + 2].y, testList[testIndex + 2].z)
// testIndex += 1
// 推进外层相机
camera.position.set(testList[testIndex].x, testList[testIndex].y, testList[testIndex].z)
controls.target = new THREE.Vector3(testList[testIndex + 2].x, testList[testIndex + 2].y, testList[testIndex + 2].z)
testIndex += 1
} else {
testList = []
testIndex = 0
}
}
}
说明
推进内层相机时,移动与转向正常,但在直角转弯处镜头转动超过90°后切回90°;
推进外层相机时,镜头突然乱切(因设置最近距离),且在直角转弯处同样存在转动>90°再切回90°现象。
方法二:绘制多条线段+animate推进
// 获取折线点数组
testInspect () {
let points = [ [2.9, 7],
[2.9, 1.6],
[2.2, 1.6],
[2.2, -5],
[8, -5],
[8, -17],
[-2, -17],
[-2, -20.4],
[-2, 5]
]
testList = this.linePointList(points, 0.6)
}
linePointList (xz, y) {
let allPoint = []
for (let i = 0; i < xz.length - 1; i++) {
if (xz[i][0] === xz[i + 1][0]) {
let gap = (xz[i][1] - xz[i + 1][1]) / 100
for (let j = 0; j < 100; j++) {
allPoint.push(new THREE.Vector3(xz[i][0], y, xz[i][1] - gap * j))
}
} else {
let gap = (xz[i][0] - xz[i + 1][0]) / 100
for (let j = 0; j < 100; j++) {
allPoint.push(new THREE.Vector3(xz[i][0] - gap * j, y, xz[i][1]))
}
}
}
return allPoint
}
// 场景动画-推进相机
animate () {
// 模仿管道的镜头推进
if (testList.length !== 0) {
if (testIndex < testList.length - 2) {
// 推进里层相机
// cameraTest.position.set(testList[testIndex].x, testList[testIndex].y, testList[testIndex].z)
// controls = new OrbitControls(cameraTest, labelRenderer.domElement)
// controls.target = new THREE.Vector3(testList[testIndex + 2].x, testList[testIndex + 2].y, testList[testIndex + 2].z)
// testIndex += 1
// 推进外层相机
camera.position.set(testList[testIndex].x, testList[testIndex].y, testList[testIndex].z)
controls.target = new THREE.Vector3(testList[testIndex + 2].x, testList[testIndex + 2].y, testList[testIndex + 2].z)
testIndex += 1
} else {
testList = []
testIndex = 0
}
}
}
说明
推进内层相机时,移动与转向正常,但直角转弯处突兀,因由多个线段拼接而成;
推进外层相机时,移动存在轻微错位(因设置最近距离),转向正常,但直角转弯处突兀,因由多个线段拼接而成。
方法三:绘制多条线段+tween动画
// 获取折线点数组
testInspect () {
let points = [
[2.9, 7],
[2.9, 1.6],
[2.2, 1.6],
[2.2, -5],
[8, -5],
[8, -17],
[-2, -17],
[-2, -20.4],
[-2, 5]
]
this.tweenCameraTest(points, 0) // tween动画-控制里层相机
// this.tweenCamera(points, 0) // tween动画-控制外层相机
}
// tween动画-控制里层相机
tweenCameraTest (point, k) {
let self = this
let count = point.length
let derection = 0
if (cameraTest.position.x === point[k][0]) {
// x相同
if (cameraTest.position.z - point[k][1] > 0) {
derection = 0
} else {
derection = Math.PI
}
} else {
// z相同
if (cameraTest.position.x - point[k][0] > 0) {
derection = Math.PI / 2
} else {
derection = - Math.PI / 2
}
}
cameraTest.rotation.y = derection
let tween = new TWEEN.Tween({
px: cameraTest.position.x, // 起始相机位置x
py: cameraTest.position.y, // 起始相机位置y
pz: cameraTest.position.z // 起始相机位置z
})
tween.to({
px: point[k][0],
py: 0.6,
pz: point[k][1]
}, 3000)
tween.onUpdate(function () {
cameraTest.position.x = this.px
cameraTest.position.y = this.py
cameraTest.position.z = this.pz
})
tween.onComplete(function () {
if (k < count - 1) {
self.tweenCameraTest(point, k + 1)
} else {
console.log('结束了!!!!!!')
}
// callBack && callBack()
})
// tween.easing(TWEEN.Easing.Cubic.InOut)
tween.start()
}
// tween动画-控制外层相机
tweenCamera (point, k) {
let self = this
let count = point.length
let derection = 0
if (camera.position.x === point[k][0]) {
// x相同
if (camera.position.z - point[k][1] > 0) {
derection = 0
} else {
derection = Math.PI
}
} else {
// z相同
if (camera.position.x - point[k][0] > 0) {
derection = Math.PI / 2
} else {
derection = - Math.PI / 2
}
}
camera.rotation.y = derection
let tween = new TWEEN.Tween({
px: camera.position.x, // 起始相机位置x
py: camera.position.y, // 起始相机位置y
pz: camera.position.z // 起始相机位置z
})
tween.to({
px: point[k][0],
py: 0.6,
pz: point[k][1]
}, 3000)
tween.onUpdate(function () {
camera.position.x = this.px
camera.position.y = this.py
camera.position.z = this.pz
})
tween.onComplete(function () {
if (k < count - 1) {
self.tweenCamera(point, k + 1)
} else {
console.log('结束了!!!!!!')
}
// callBack && callBack()
})
// tween.easing(TWEEN.Easing.Cubic.InOut)
tween.start()
}
说明
控制内层相机使用tweenCameraTest()方法,移动正常,通过rotation.y直接控制转向,转弯略突兀因无rotation.y动画;
控制外层相机使用tweenCamera()方法,移动存在轻微错位(因设置最近距离),转向完全失控,似乎始终看向坐标原点。
方法四:优化方案一,绘制单条折线+animate推进
// 获取折线点数组
testInspect () {
// 描折线点,为了能使一条折线能直角转弯,特添加“用于直角转折”的辅助点,尝试将所有标为“用于直角转折”的点去掉,折线马上变曲线。
let curve = new THREE.CatmullRomCurve3([
new THREE.Vector3(2.9, 0.6, 7),
new THREE.Vector3(2.9, 0.6, 1.6),
new THREE.Vector3(2.89, 0.6, 1.6), // 用于直角转折
new THREE.Vector3(2.2, 0.6, 1.6),
new THREE.Vector3(2.2, 0.6, 1.59), // 用于直角转折
new THREE.Vector3(2.2, 0.6, -5),
new THREE.Vector3(2.21, 0.6, -5), // 用于直角转折
new THREE.Vector3(8, 0.6, -5),
new THREE.Vector3(8, 0.6, -5.01), // 用于直角转折
new THREE.Vector3(8, 0.6, -17),
new THREE.Vector3(7.99, 0.6, -17), // 用于直角转折
new THREE.Vector3(-2, 0.6, -17),
new THREE.Vector3(-2, 0.6, -17.01), // 用于直角转折
new THREE.Vector3(-2, 0.6, -20.4),
new THREE.Vector3(-2, 0.6, 5),
])
let material = new THREE.LineBasicMaterial({color: 0x3cf0fa})
let geometry = new THREE.Geometry()
let gap = 500
for (let i = 0; i < gap; i++) {
let index = i / gap
let point = curve.getPointAt(index)
let position = point.clone()
testList.push(position) // 通过此方法获取点比curve.getPoints(1500)更好,不信你试试,用getPoints获取,镜头会有明显的俯视效果不知为何。
geometry.vertices.push(position)
}
let line = new THREE.Line(geometry, material) // 连成线
scene.add(line) // 加入到场景中
}
// 场景动画-推进外层相机
animate () {
// 模仿管道的镜头推进
if (testList.length !== 0) {
if (testIndex < testList.length - 2) {
// 推进里层相机
// cameraTest.position.set(testList[testIndex].x, testList[testIndex].y, testList[testIndex].z)
// controls = new OrbitControls(cameraTest, labelRenderer.domElement)
// 推进外层相机
// camera.position.set(testList[testIndex].x, testList[testIndex].y + 0.01, testList[testIndex].z)
controls.object.position.set(testList[testIndex].x, testList[testIndex].y + 0.01, testList[testIndex].z) // 稍微讲相机位置上移,就不会出现似乎乱切镜头穿过旁边物体的效果。
controls.target = testList[testIndex + 2]
// controls.target = new THREE.Vector3(testList[testIndex + 2].x, testList[testIndex + 2].y, testList[testIndex + 2].z)
testIndex += 1
} else {
testList = []
testIndex = 0
}
}
}
说明
解决了直角转弯处镜头转动>90°再切回90°的问题。
解决了推进外层相机镜头乱切的问题。
然而,相机在转弯时仍有明显后闪(因设置最近距离),并非严格沿折线前进。
以上为three.js镜头追踪移动效果实例的详细内容,更多关于three.js镜头追踪移动的资料请关注本站其它相关文章!