Third Person perspective – Three js

I’m working on a Three.js project where I need a third-person camera setup that follows a 3D model as it moves around the scene. I have a basic implementation with animations and controls, but I’m struggling to make the camera follow the model properly. Here’s a summary of what I’m trying to achieve and the current issues I’m facing:

Problem Summary:

I want the camera to follow the model from a third-person perspective. Specifically, as the model moves in different directions (forward, backward, left, right), the camera should always follow the model while maintaining a fixed distance and orientation.

Current Implementation:

<script setup>
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { onMounted, ref } from 'vue';

const container3D = ref(null);

onMounted(() => {
    let object, mixer, walkAction, shutdownAction, CycleBackAction, jumpAction;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0xf0f0f0);
    const cameraOffset = new THREE.Vector3(0, 30, -50); 
    const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 1, 5000);

    scene.add(camera);

    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    container3D.value.appendChild(renderer.domElement);

    const loader = new GLTFLoader();
    loader.load(
        '/medium_mech_striker/scene.gltf',
        (gltf) => {
            object = gltf.scene;
            object.scale.set(10, 10, 10);
            object.position.y = -200;
            object.rotateY(Math.PI);
            object.frustumCulled = false;
            scene.add(object);

            // Setup animations
            mixer = new THREE.AnimationMixer(object);
            gltf.animations.forEach((clip) => {
                if (clip.name === 'a5WalkCycle') {
                    walkAction = mixer.clipAction(clip);
                    walkAction.setEffectiveTimeScale(1); // Adjust this value to control the speed of the walk animation
                } else if (clip.name === 'a1ShutdownPose') {
                    shutdownAction = mixer.clipAction(clip);
                } else if (clip.name === 'a6WalkCycleBack') {
                    CycleBackAction = mixer.clipAction(clip);
                } else if (clip.name === 'a8Jump') {
                    jumpAction = mixer.clipAction(clip);
                }
            });

            // Start with shutdown animation
            if (shutdownAction) {
                shutdownAction.play();
            }
        },
        undefined,
        (error) => {
            console.error('An error happened while loading the GLTF model:', error);
        }
    );

    // Create plane mesh
    const planeGeometry = new THREE.PlaneGeometry(10000, 10000);
    planeGeometry.rotateX(-Math.PI / 2);
    const planeMaterial = new THREE.ShadowMaterial({ color: 0x000000, opacity: 0.2 });

    const plane = new THREE.Mesh(planeGeometry, planeMaterial);
    plane.receiveShadow = true;
    scene.add(plane);

    const GridHelper = new THREE.GridHelper(2000, 100);
    GridHelper.position.y = -199;
    GridHelper.material.opacity = 0.25;
    GridHelper.material.transparent = true;
    scene.add(GridHelper);

    // Light
    scene.add(new THREE.AmbientLight(0xf0f0f0, 3));
    const light = new THREE.SpotLight(0xffffff, 4.5);
    light.position.set(0, 1500, 200);
    light.angle = Math.PI * 0.2;
    light.decay = 0;
    light.castShadow = true;
    light.shadow.camera.near = 200;
    light.shadow.camera.far = 2000;
    light.shadow.bias = -0.000222;
    light.shadow.mapSize.width = 1024;
    light.shadow.mapSize.height = 1024;
    scene.add(light);

    // Orbit controls
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;
    controls.dampingFactor = 0.25;
    controls.screenSpacePanning = false;

    // Handle keyboard input
    const keys = {};
    window.addEventListener('keydown', (event) => {
        keys[event.key] = true;
    });
    window.addEventListener('keyup', (event) => {
        keys[event.key] = false;
    });

    // Animation function
    function animate() {
        requestAnimationFrame(animate);

        // Update mixer if defined
        if (mixer) {
            mixer.update(0.03); // Adjust this value to control the overall animation update speed
        }

        // Handle animations
        if (object) {
            if (keys['w']) {
                if (shutdownAction && shutdownAction.isRunning()) {
                    shutdownAction.stop();
                }
                if (walkAction && !walkAction.isRunning()) {
                    walkAction.play();
                }

                if (keys['d']) {
                    object.rotation.y += 0.05;
                } else if (keys['a']) {
                    object.rotation.y -= 0.05;
                } else if (keys[' ']) {
                    if (!jumpAction.isRunning()) jumpAction.play();
                } else {
                    if (jumpAction.isRunning()) jumpAction.stop();
                }

                const direction = new THREE.Vector3();
                object.getWorldDirection(direction);
                object.position.addScaledVector(direction, 1);
            } else if (keys['s']) {
                if (walkAction && walkAction.isRunning()) {
                    walkAction.stop();
                }

                if (CycleBackAction && !CycleBackAction.isRunning()) {
                    CycleBackAction.play();
                }

                const direction = new THREE.Vector3();
                object.getWorldDirection(direction);
                object.position.addScaledVector(direction, -1);
            } else {
                if (walkAction && walkAction.isRunning()) {
                    walkAction.stop();
                }
                if (shutdownAction && !shutdownAction.isRunning()) {
                    shutdownAction.play();
                }
                if (CycleBackAction && CycleBackAction.isRunning()) {
                    CycleBackAction.stop();
                }
            }
        }
        // Update camera position to follow the object
        const objectPosition = new THREE.Vector3();
        object.getWorldPosition(objectPosition);

        // Calculate camera position based on object position and offset
        const offset = cameraOffset.clone();
        offset.applyMatrix4(object.matrixWorld);
        camera.position.copy(objectPosition).add(offset);

        // Ensure camera always faces the object
        camera.lookAt(objectPosition);

        controls.update();
        renderer.render(scene, camera);
    }

    // Start animation
    animate();

    // Adjust camera and renderer size when window is resized
    window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    });
});
</script>

Issue:

The camera currently doesn’t follow the model’s movements. The camera remains static and doesn’t adjust its position or orientation based on the model’s position or rotation.

What I’ve Tried:

I have set up the camera and renderer properly.
I’ve implemented animation controls for moving and rotating the model.
I’ve used OrbitControls to manage the camera view.

Questions:
How can I modify the camera setup so that it follows the model from a third-person perspective?
What changes are needed in the code to ensure that the camera maintains a fixed distance from the model while it moves around?
Any guidance or suggestions would be greatly appreciated. Thank you!