Using r3f to update camera rotation/lookAt value

I’m pretty new to r3f and ThreeJS in general, I’m trying to achieve an effect where the camera starts on the outside of the model at [0,50,50] for instance, then animates to the centre of point at [0,20,0]. From here it will be able to spin around the centre point and look at different points on the model which is a ring around it. I have got the position animation down but for some reason I cannot figure out how to change the rotation/lookAt value (im not even 100% on which one I should be using), it always seems to be set to [0,0,0].

You’ll see at the start i’m logging the positon and rotation to see where the camera is, this returns correct values that i’ve used to test it like [30,0,0] just to offset it and get a different results but there is no visible change. I feel like this is something I’m either approaching wrong or missing a trick here. Eventually I want to add in mouse tracking to look around but I seem to be running into a similar issue where nothing is updated despite values being logged.

import { useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, PerspectiveCamera } from '@react-three/drei';
import gsap from 'gsap';
import * as THREE from 'three';

function CameraController({ rotation, lookAtPosition, shouldAnimate, onAnimationComplete }) {
  const cameraRef = useRef();
  const controlsRef = useRef();
  const { set } = useThree();

  useEffect(() => {
    const lookAtVector = new THREE.Vector3(lookAtPosition);
    cameraRef.current.lookAt(...lookAtVector);
    console.log(cameraRef.current.position);
    console.log(cameraRef.current.rotation);
  }, []);

  useEffect(() => {
    if (cameraRef.current) {
      set({ camera: cameraRef.current });
      const lookAtVector = new THREE.Vector3(...lookAtPosition);
      cameraRef.current.lookAt(lookAtVector);
      cameraRef.current.updateProjectionMatrix();

      console.log(cameraRef.current.rotation); 
    }
  }, [set, lookAtPosition]);

  useEffect(() => {
    if (shouldAnimate && cameraRef.current) {
      gsap.to(cameraRef.current.position, {
        duration: 2,
        x: 0,
        y: 20,
        z: 20,
        ease: 'power2.out',
        onUpdate: () => {
          cameraRef.current.updateProjectionMatrix();
        },
        onComplete: () => {
          const lookAtVector = new THREE.Vector3(0, 20, 20);
          cameraRef.current.lookAt(lookAtVector);
          cameraRef.current.updateProjectionMatrix();
          console.log(cameraRef.current.rotation); 
          if (onAnimationComplete) onAnimationComplete();
        },
      });
    }
  }, [shouldAnimate, onAnimationComplete]);

  useFrame(() => {
    if (controlsRef.current) {
      controlsRef.current.update();
    }
  });

  return (
    <>
      <perspectiveCamera ref={cameraRef} position={[0,25, 0]}/>
      {cameraRef.current && (
        <OrbitControls
          ref={controlsRef}
          args={[cameraRef.current]}
          enableZoom={false}
          enablePan={true}
          enableRotate={true}
          minDistance={10}
          maxDistance={100}
          dampingFactor={0.2}
          rotateSpeed={0.5}
        /> 
      )} 
    </>
  );
}

export default CameraController;

You don’t need to assign a ref to the camera component. Instead import the camera as an object using the useThree hook.
Also in React for better functionality create States which makes the scene re-render every time it changes and hence update the changes. For Example for lookAtVector instead of doing:

const lookAtVector = new THREE.Vector3(lookAtPosition)

you can do

const [ lookAtVector, setLookAtVector ] = useState(new THREE.Vector3(looAtPosition));

Keep in mind that when you create a new Canvas using R3F it automatically adds a PerspectiveCamera to the scene, so when the camera object is imported using the useThree hook it automatically targets the pre added camera. So now if you want to change the position of the camera all you have to do is:

const { set, camera } = useThree();

useEffect(() => {
      camera.position.set(0,25,0);
      camera.updateProjectionMatrix();
}, [])

This way you can make the code formatting better with respect to React, also even after this the issue still persist than I would suggest to force render a frame.

1 Like

I use camera controls for this, there is a drei component for it as well, here’s an example Enter portals - CodeSandbox

Animating the camera with an existing control is not trivial because the control animates the camera every frame, if something writes to the camera it’s a race condition. C-c fixes that because you can animate through the control.

In your case for instance you have a camera controlled by orbit controls. If gsap now animates the camera o-c will fight against it.

Ps. GitHub - yomotsu/camera-controls: A camera control for three.js, similar to THREE.OrbitControls yet supports smooth transitions and more features. It’s a wonderful library, complete replacement of orbit controls and much more capable.

1 Like

You should definitely hear what this guy has to say. He’s the goat of R3F!

1 Like

Thanks for all the help here guys, some really useful points. I rewrote the code and managed to fix quite a bit of it, but I’ve also updated my component to use the camera object directly within Three, removed the OrbitControls and setup the starting code that I’ll update to move the camera around using mouse tracking.

I’m still curious to know if this is better achieved in CameraControls though, as at the moment I’m directly manipulating the perspectiveCamera. If I wanted to add damping effects to the yaw and pitch or a raycaster (I intend on adding both), is that going to be an issue with this current method?

import { useEffect, useState } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import gsap from 'gsap';
import * as THREE from 'three';

function CameraController({ shouldAnimate }) {
  const { set, gl, camera } = useThree();
  const [isAnimating, setIsAnimating] = useState(false);
  const [targetLookAt, setTargetLookAt] = useState(new THREE.Vector3(0, 10, -30));
  useEffect(() => {
    console.log()
    if (camera) {
      camera.position.set(-30, 30, 30);
      camera.lookAt(0, 0, 0);
      camera.updateProjectionMatrix();
    }
  }, [set]);

  useEffect(() => {
    if (shouldAnimate && camera) {
      setIsAnimating(true);

      const finalPosition = { x: 0, y: 10, z: 0 };

      const timeline = gsap.timeline({
        onUpdate: () => {
          camera.updateProjectionMatrix();
        },
        onComplete: () => {
          setIsAnimating(false);
          camera.lookAt(targetLookAt);
          document.addEventListener('mousemove', handleMouseMove);
        },
      });

      // Animate the camera position
      timeline.to(camera.position, {
        duration: 2,
        x: finalPosition.x,
        y: finalPosition.y,
        z: finalPosition.z,
        ease: 'power2.out',
        onUpdate: () => {
          const currentPos = new THREE.Vector3(
            camera.position.x,
            camera.position.y,
            camera.position.z
          );
          const targetQuat = new THREE.Quaternion().setFromRotationMatrix(
            new THREE.Matrix4().lookAt(currentPos, targetLookAt, new THREE.Vector3(0, 1, 0))
          );
          camera.quaternion.slerp(targetQuat, 0.1);
        }
      });

    }

    return () => {
      gsap.killTweensOf(camera.position);
      document.removeEventListener('mousemove', handleMouseMove);
    };
  }, [shouldAnimate]);

  const handleMouseMove = (event) => {
    if (isAnimating) return;

    const movementX = event.movementX || event.mozMovementX || event.webkitMovementX || 0;
    const movementY = event.movementY || event.mozMovementY || event.webkitMovementY || 0;

    camera.rotation.order = 'YXZ';
    camera.rotation.y -= movementX * 0.002; // Yaw (horizontal rotation)
    camera.rotation.x -= movementY * 0.002; // Pitch (vertical rotation)

    camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x));
  };

  useFrame(() => {
    if (isAnimating) {
      // If using controls, update them here
    }
  });

  return (
    <perspectiveCamera
      fov={75}
      aspect={gl.domElement.clientWidth / gl.domElement.clientHeight}
      near={0.1}
      far={200}
    />
  );
}

export default CameraController;

No it will be totally fine, you should take a look at the documentation there you can find more helpful hooks and methods to make your workflow faster.

1 Like

Cool thanks, I was able to get the mouse tracking working and add a raycaster so I can update the content when certain meshes come into view, adding my code here for anyone who needs to use a similar solution.

import { useEffect, useRef } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import gsap from 'gsap';
import * as THREE from 'three';

function CameraController({ shouldAnimate }) {
  const { gl, camera, scene } = useThree();
  const isAnimating = useRef(false);
  const targetLookAt = useRef(new THREE.Vector3(0, 10, -30));
  const mousePosition = useRef(new THREE.Vector2(0, 0));
  const velocity = useRef(new THREE.Vector2(0, 0));
  const dampingFactor = 0.9; // Damping factor for smooth slowing down
  const accelerationFactor = 0.02; // Acceleration factor for responsiveness
  const deadZoneSize = 0.1; // Define the size of the dead zone as a percentage of screen width/height

  const raycaster = useRef(new THREE.Raycaster());

  useEffect(() => {
    if (camera) {
      // Set initial camera position and look at the target
      camera.position.set(-30, 30, 30);
      camera.lookAt(0, 0, 0);
      camera.updateProjectionMatrix();
    }
  }, [camera]);

  useEffect(() => {
    const handleMouseMove = (event) => {
      const rect = gl.domElement.getBoundingClientRect();
      mousePosition.current.x = (event.clientX - rect.left) / rect.width * 2 - 1;
      mousePosition.current.y = -((event.clientY - rect.top) / rect.height * 2 - 1);
    };

    if (shouldAnimate && camera) {
      isAnimating.current = true;

      const finalPosition = { x: 0, y: 20, z: 0 };

      const timeline = gsap.timeline({
        onUpdate: () => {
          camera.updateProjectionMatrix();
        },
        onComplete: () => {
          isAnimating.current = false;
          camera.lookAt(targetLookAt.current);
          document.addEventListener('mousemove', handleMouseMove);
        },
      });

      // Animate the camera position
      timeline.to(camera.position, {
        duration: 2,
        x: finalPosition.x,
        y: finalPosition.y,
        z: finalPosition.z,
        ease: 'power2.out',
        onUpdate: () => {
          const currentPos = new THREE.Vector3(
            camera.position.x,
            camera.position.y,
            camera.position.z
          );
          const targetQuat = new THREE.Quaternion().setFromRotationMatrix(
            new THREE.Matrix4().lookAt(currentPos, targetLookAt.current, new THREE.Vector3(0, 1, 0))
          );
          camera.quaternion.slerp(targetQuat, 0.1);
        },
      });
    }

    return () => {
      gsap.killTweensOf(camera.position);
      document.removeEventListener('mousemove', handleMouseMove);
    };
  }, [shouldAnimate, camera, gl.domElement]);

  useFrame(() => {
    if (!isAnimating.current) {
      const { x, y } = mousePosition.current;

      // Calculate the dead zone bounds
      const deadZoneMinX = -deadZoneSize;
      const deadZoneMaxX = deadZoneSize;
      const deadZoneMinY = -deadZoneSize;
      const deadZoneMaxY = deadZoneSize;

      // Calculate the velocity based on mouse position
      if (x < deadZoneMinX) {
        velocity.current.x = (x + deadZoneSize) * accelerationFactor;
      } else if (x > deadZoneMaxX) {
        velocity.current.x = (x - deadZoneSize) * accelerationFactor;
      } else {
        velocity.current.x = 0;
      }

      if (y < deadZoneMinY) {
        velocity.current.y = (y + deadZoneSize) * accelerationFactor;
      } else if (y > deadZoneMaxY) {
        velocity.current.y = (y - deadZoneSize) * accelerationFactor;
      } else {
        velocity.current.y = 0;
      }

      // Apply damping to the velocity
      velocity.current.x *= dampingFactor;
      velocity.current.y *= dampingFactor;

      // Update camera rotation based on velocity
      camera.rotation.order = 'YXZ';
      camera.rotation.y -= velocity.current.x; // Yaw (horizontal rotation)
      camera.rotation.x += velocity.current.y; // Pitch (vertical rotation) - INVERTED HERE

      // Clamp the pitch rotation
      camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x));
    }

    // Update the raycaster direction based on the camera
    raycaster.current.setFromCamera(mousePosition.current, camera);

    // Get the list of objects the raycaster intersects
    const intersects = raycaster.current.intersectObjects(scene.children, true);

    if (intersects.length > 0) {
      console.log('Looking at:', intersects[0].object);
    }
  });

  return (
    <perspectiveCamera
      fov={75}
      aspect={gl.domElement.clientWidth / gl.domElement.clientHeight}
      near={0.1}
      far={200}
    />
  );
}

export default CameraController;

Instead of making a useEffect just for getting the mousePosition, import the pointer object from useThree which does the same thing for you but in a more effective way.

so instead of doing:

useEffect(() => {
    const handleMouseMove = (event) => {
      const rect = gl.domElement.getBoundingClientRect();
      mousePosition.current.x = (event.clientX - rect.left) / rect.width * 2 - 1;
      mousePosition.current.y = -((event.clientY - rect.top) / rect.height * 2 - 1);
    };
)

You can simply do:

const {set, raycaster, pointer} = useThree();
// Here `pointer` is a `Vector2` which contains the `x` and the `y` position of the mouse

console.log(pointer.x, pointer.y)