In this custom implementation of a follow cam, a.k.a., third person camera, I have a camera pivot, which I attach to an object that I want to follow. The pivot can be rotated by moving the mouse left, right, up and down.
The pivot has a yaw and pitch THREE.Object3D, and the camera added to the bottom of the hierarchy offset some distance. The distance can be changed using the scroll wheel. While the yaw and pitch are controlled by the mouse movement.
The pivot follows the main character of the scene. The pivot lerps to the position of the character collider.
import*asTHREEfrom'three'import{GLTFLoader}from'three/examples/jsm/loaders/GLTFLoader'importStatsfrom'three/examples/jsm/libs/stats.module'import*asCANNONfrom'cannon-es'importCannonDebugRendererfrom'./utils/cannonDebugRenderer'constscene=newTHREE.Scene()constlight1=newTHREE.SpotLight(0xffffff,100)light1.position.set(2.5,5,2.5)light1.angle=Math.PI/8light1.penumbra=0.5light1.castShadow=truelight1.shadow.mapSize.width=1024light1.shadow.mapSize.height=1024light1.shadow.camera.near=0.5light1.shadow.camera.far=20scene.add(light1)constlight2=newTHREE.SpotLight(0xffffff,100)light2.position.set(-2.5,5,2.5)light2.angle=Math.PI/8light2.penumbra=0.5light2.castShadow=truelight2.shadow.mapSize.width=1024light2.shadow.mapSize.height=1024light2.shadow.camera.near=0.5light2.shadow.camera.far=20scene.add(light2)constcamera=newTHREE.PerspectiveCamera(75,window.innerWidth/window.innerHeight,0.01,100)camera.position.set(0,0,2)constpivot=newTHREE.Object3D()pivot.position.set(0,1,10)constyaw=newTHREE.Object3D()constpitch=newTHREE.Object3D()scene.add(pivot)pivot.add(yaw)yaw.add(pitch)pitch.add(camera)constrenderer=newTHREE.WebGLRenderer()renderer.setSize(window.innerWidth,window.innerHeight)renderer.shadowMap.enabled=truedocument.body.appendChild(renderer.domElement)constworld=newCANNON.World()world.gravity.set(0,-9.82,0)constgroundMaterial=newCANNON.Material('groundMaterial')constslipperyMaterial=newCANNON.Material('slipperyMaterial')constslippery_ground_cm=newCANNON.ContactMaterial(groundMaterial,slipperyMaterial,{friction:0,restitution:0.3,contactEquationStiffness:1e8,contactEquationRelaxation:3,})world.addContactMaterial(slippery_ground_cm)// Character ColliderconstcharacterCollider=newTHREE.Object3D()characterCollider.position.y=3scene.add(characterCollider)constcolliderShape=newCANNON.Sphere(0.5)constcolliderBody=newCANNON.Body({mass:1,material:slipperyMaterial})colliderBody.addShape(colliderShape,newCANNON.Vec3(0,0.5,0))colliderBody.addShape(colliderShape,newCANNON.Vec3(0,-0.5,0))colliderBody.position.set(characterCollider.position.x,characterCollider.position.y,characterCollider.position.z)colliderBody.linearDamping=0.95colliderBody.angularFactor.set(0,1,0)// prevents rotation X,Z axisworld.addBody(colliderBody)letmixer:THREE.AnimationMixerletmodelReady=falseletmodelMesh:THREE.Object3DconstanimationActions:THREE.AnimationAction[]=[]letactiveAction:THREE.AnimationActionletlastAction:THREE.AnimationActionconstgltfLoader=newGLTFLoader()gltfLoader.load('models/eve.glb',(gltf)=>{gltf.scene.traverse(function(child){if((childasTHREE.Mesh).isMesh){letm=childm.receiveShadow=truem.castShadow=truem.frustumCulled=false;(masTHREE.Mesh).geometry.computeVertexNormals()if((childasTHREE.Mesh).material){constmat=(childasTHREE.Mesh).material;(matasTHREE.Material).transparent=false;(matasTHREE.Material).side=THREE.FrontSide}}})mixer=newTHREE.AnimationMixer(gltf.scene)letanimationAction=mixer.clipAction(gltf.animations[0])animationActions.push(animationAction)activeAction=animationActions[0]scene.add(gltf.scene)modelMesh=gltf.scenelight1.target=modelMeshlight2.target=modelMesh//add an animation from another filegltfLoader.load('models/eve@walking.glb',(gltf)=>{console.log('loaded Eve walking')letanimationAction=mixer.clipAction(gltf.animations[0])animationActions.push(animationAction)gltfLoader.load('models/eve@jump.glb',(gltf)=>{console.log('loaded Eve jump')gltf.animations[0].tracks.shift()//delete the specific track that moves the object up/down while jumpingletanimationAction=mixer.clipAction(gltf.animations[0])animationActions.push(animationAction)//progressBar.style.display = 'none'modelReady=truesetAction(animationActions[1],true)},(xhr)=>{if(xhr.lengthComputable){//const percentComplete = (xhr.loaded / xhr.total) * 100//progressBar.value = percentComplete//progressBar.style.display = 'block'}},(error)=>{console.log(error)})},(xhr)=>{if(xhr.lengthComputable){//const percentComplete = (xhr.loaded / xhr.total) * 100//progressBar.value = percentComplete//progressBar.style.display = 'block'}},(error)=>{console.log(error)})},(xhr)=>{if(xhr.lengthComputable){//const percentComplete = (xhr.loaded / xhr.total) * 100//progressBar.value = percentComplete//progressBar.style.display = 'block'}},(error)=>{console.log(error)})constsetAction=(toAction:THREE.AnimationAction,loop:Boolean)=>{if(toAction!=activeAction){lastAction=activeActionactiveAction=toActionlastAction.fadeOut(0.1)activeAction.reset()activeAction.fadeIn(0.1)activeAction.play()if(!loop){activeAction.clampWhenFinished=trueactiveAction.loop=THREE.LoopOnce}}}letmoveForward=falseletmoveBackward=falseletmoveLeft=falseletmoveRight=falseletcanJump=trueconstcontactNormal=newCANNON.Vec3()constupAxis=newCANNON.Vec3(0,1,0)colliderBody.addEventListener('collide',function(e:any){constcontact=e.contactif(contact.bi.id==colliderBody.id){contact.ni.negate(contactNormal)}else{contactNormal.copy(contact.ni)}if(contactNormal.dot(upAxis)>0.5){if(!canJump){setAction(animationActions[1],true)}canJump=true}})constplaneGeometry=newTHREE.PlaneGeometry(100,100)consttexture=newTHREE.TextureLoader().load('img/grid.png')constplane=newTHREE.Mesh(planeGeometry,newTHREE.MeshPhongMaterial({map:texture}))plane.rotateX(-Math.PI/2)plane.receiveShadow=truescene.add(plane)constplaneShape=newCANNON.Plane()constplaneBody=newCANNON.Body({mass:0,material:groundMaterial})planeBody.addShape(planeShape)planeBody.quaternion.setFromAxisAngle(newCANNON.Vec3(1,0,0),-Math.PI/2)world.addBody(planeBody)constboxes:CANNON.Body[]=[]constboxMeshes:THREE.Mesh[]=[]for(leti=0;i<25;i++){consthalfExtents=newCANNON.Vec3(Math.random()*2,Math.random()*2,Math.random()*2)constboxShape=newCANNON.Box(halfExtents)constboxGeometry=newTHREE.BoxGeometry(halfExtents.x*2,halfExtents.y*2,halfExtents.z*2)constx=(Math.random()-0.5)*20consty=2+i*2constz=(Math.random()-0.5)*20constboxBody=newCANNON.Body({mass:1,material:groundMaterial})boxBody.addShape(boxShape)constboxMesh=newTHREE.Mesh(boxGeometry,newTHREE.MeshStandardMaterial())world.addBody(boxBody)scene.add(boxMesh)boxBody.position.set(x,y,z)boxMesh.castShadow=trueboxMesh.receiveShadow=trueboxes.push(boxBody)boxMeshes.push(boxMesh)}window.addEventListener('resize',onWindowResize,false)functiononWindowResize(){camera.aspect=window.innerWidth/window.innerHeightcamera.updateProjectionMatrix()renderer.setSize(window.innerWidth,window.innerHeight)render()}functiononDocumentMouseMove(e:MouseEvent){e.preventDefault()yaw.rotation.y-=e.movementX*0.002constv=pitch.rotation.x-e.movementY*0.002if(v>-1&&v<0.1){pitch.rotation.x=v}}functiononDocumentMouseWheel(e:WheelEvent){e.preventDefault()constv=camera.position.z+e.deltaY*0.005if(v>=0.5&&v<=5){camera.position.z=v}}constmenuPanel=document.getElementById('menuPanel')asHTMLDivElementconststartButton=document.getElementById('startButton')asHTMLInputElementstartButton.addEventListener('click',()=>{renderer.domElement.requestPointerLock()},false)letpointerLocked=falsedocument.addEventListener('pointerlockchange',()=>{if(document.pointerLockElement===renderer.domElement){pointerLocked=truestartButton.style.display='none'menuPanel.style.display='none'document.addEventListener('keydown',onDocumentKey,false)document.addEventListener('keyup',onDocumentKey,false)renderer.domElement.addEventListener('mousemove',onDocumentMouseMove,false)renderer.domElement.addEventListener('wheel',onDocumentMouseWheel,false)}else{menuPanel.style.display='block'document.removeEventListener('keydown',onDocumentKey,false)document.removeEventListener('keyup',onDocumentKey,false)renderer.domElement.removeEventListener('mousemove',onDocumentMouseMove,false)renderer.domElement.removeEventListener('wheel',onDocumentMouseWheel,false)setTimeout(()=>{startButton.style.display='block'},1000)}})constkeyMap:{[id:string]:boolean}={}constonDocumentKey=(e:KeyboardEvent)=>{keyMap[e.code]=e.type==='keydown'if(pointerLocked){moveForward=keyMap['KeyW']moveBackward=keyMap['KeyS']moveLeft=keyMap['KeyA']moveRight=keyMap['KeyD']if(keyMap['Space']){if(canJump===true){colliderBody.velocity.y=10setAction(animationActions[2],false)}canJump=false}}}constinputVelocity=newTHREE.Vector3()constvelocity=newCANNON.Vec3()consteuler=newTHREE.Euler()constquat=newTHREE.Quaternion()constv=newTHREE.Vector3()consttargetQuaternion=newTHREE.Quaternion()letdistance=0conststats=newStats()document.body.appendChild(stats.dom)constclock=newTHREE.Clock()letdelta=0//const cannonDebugRenderer = new CannonDebugRenderer(scene, world)functionanimate(){requestAnimationFrame(animate)if(modelReady){if(canJump){//walkingmixer.update(delta*distance*5)}else{//were in the airmixer.update(delta)}constp=characterCollider.positionp.y-=1modelMesh.position.y=characterCollider.position.ydistance=modelMesh.position.distanceTo(p)constrotationMatrix=newTHREE.Matrix4()rotationMatrix.lookAt(p,modelMesh.position,modelMesh.up)targetQuaternion.setFromRotationMatrix(rotationMatrix)if(!modelMesh.quaternion.equals(targetQuaternion)){modelMesh.quaternion.rotateTowards(targetQuaternion,delta*10)}if(canJump){inputVelocity.set(0,0,0)if(moveForward){inputVelocity.z=-10*delta}if(moveBackward){inputVelocity.z=10*delta}if(moveLeft){inputVelocity.x=-10*delta}if(moveRight){inputVelocity.x=10*delta}// apply camera rotation to inputVelocityeuler.y=yaw.rotation.yeuler.order='XYZ'quat.setFromEuler(euler)inputVelocity.applyQuaternion(quat)}modelMesh.position.lerp(characterCollider.position,0.1)}velocity.set(inputVelocity.x,inputVelocity.y,inputVelocity.z)colliderBody.applyImpulse(velocity)delta=Math.min(clock.getDelta(),0.1)world.step(delta)//cannonDebugRenderer.update()characterCollider.position.set(colliderBody.position.x,colliderBody.position.y,colliderBody.position.z)boxes.forEach((b,i)=>{boxMeshes[i].position.set(b.position.x,b.position.y,b.position.z)boxMeshes[i].quaternion.set(b.quaternion.x,b.quaternion.y,b.quaternion.z,b.quaternion.w)})characterCollider.getWorldPosition(v)pivot.position.lerp(v,0.1)render()stats.update()}functionrender(){renderer.render(scene,camera)}animate()