Camera Transition Animation

A guide for adding animated camera transitions to your configuration setup

Camera Animation Example

Overview

This guide will walk you through the steps involved in adding smooth camera transitions between camera positions.

The Threekit platform does not currently offer an animation toolset through the UI to achieve this effect. Instead, the approach we need to take is to leverage the power of the Player API to build our own camera animation setup.

This guide provides you with a ready-to-use script that can be added to your scene asset, and a set of instructions for how to control the animation.

Configuration Setup

Step by Step Setup
1

Camera Attribute Setup

The script expects a camera name attribute of type String, on the scene asset where the script is added. This attribute should store the name of the target camera where you want to animate.

If your camera angle attribute is of type Asset/Part Reference, storing Items as options, then you will need to create a separate string attribute that stores the name of the camera nodes from the scene. These string names could be stored as metadata on the items representing each camera option. You would then have to create an additional rule to read that metadata from the camera item and set its value to the camera string attribute.

You will then need to set the constant named cameraAttributeName in the script to hold the name of your string camera name attribute, as shown below.

Ensure that your scene contains a set of cameras corresponding to each of the options on this string attribute, and that their names correspond with the string values.

For example, for a CamAngle attribute with options of "Main", "Front", and "Back" you will need to create a set of three cameras in the scene named "Main", "Front", and "Back".

2

Camera Setup

The camera settings are important, as the script is currently designed to animate only between position and rotations of the cameras. Other parameters like lens focal length, control mode, or constraints won't get considered.

The player camera will be set to the same settings as the scene camera corresponding to the default camera option. Ensure that all your cameras are essentially the same as far as focal length, control mode and constraints as the default starting camera, so that there is no confusion.

3

Camera Orbit Target Node

Another important element in the animation is the focal point of the camera. When transitioning from one camera angle to another we generally want to ensure that we keep the camera facing at the product. This can be done with the aid of a Null node in the scene, which we can use as our orbit target.

Position the Null node where you want the camera to focus during the transition, and then edit the script to provide the name of the null node to the orbitTargetNodeName constant listed at the top of the script.

4

Animation Time Attribute

The third important element in the animation is the duration. This determines how fast the transition takes place from one camera angle to another.

The script provides control for this through the use of an attribute on the asset where you have the script added. It expects that you create a number attribute with the name CamAnimationTime, and that you give it a value that represents the number of seconds. This can be a floating point value as well, such as 1.25, which would represent a duration of one and a quarter seconds (a total of 1250 milliseconds).

If you wish to use a different name for your attribute, then you can edit the script at the top, to set your own custom attribute name on the cameraAnimationTime constant.

5

Debugging

For issues with the above steps, and other animation issues, the script offers the option to display helpful messages in the browser console, that may help you debug the issues.

By default the script below has set the debug feature to OFF. To enable it, change the logDebug constant to true at the top of the script.

6

Player Camera

You can leave the scene player camera empty, as the script will automatically make a copy at runtime of the camera corresponding to the default value for your cameraAttributeNameattribute, and set it as the active camera upon player initialization.

Script Setup

Once the required steps listed above have been completed, the script below will need to be added to the scene asset.

The script needs to be copied and pasted in a custom code action, inside a rule on your scene asset.

Once you have pasted it, and every time you make a change, you need to save the changes using the little Disk icon below the script window, in addition to saving the changes to your asset.

/**
 * Script for Dynamic Camera Animation in Threekit
 *
 * Description:
 * This script dynamically finds the camera with the same name as the value passed to the attribute specified by cameraAttributeName
 * It then animates the player camera in a smooth transition from the current position to the selected camera's location and rotation.
 *
 * Key Features:
 * - Reads the camera name from the "cameraAttributeName" attribute in the configurator,
 *   matching it with the scene node name for accurate selection.
 * - Clones the default camera node and sets it as the active player camera.
 * - Reads the target node name from the "orbitTargetNodeName" to use as the target for the camera to look at during the animation
 * - Supports smooth cubic easing animation for camera transitions.
 * - Reads "cameraAnimationTime" attribute for dynamic control of the animation duration time.
 * - If the camera orbit target node is not found, the camera will animate to the origin (0, 0, 0) instead.
 * - The animation will maintain the source and target cameras' offset and distance from the orbit target node.
 * - Use the "logDebug" variable to log debug information to the console if problems arise.
 * - Since the script will get executed on every configurator change, it will only animate the camera if the camera attribute has changed.
 * - The script will also animate the orbit node rotation if the camera control mode is set to Node/Turntable.
 */

const cameraAttributeName = "CamAngle"; //rename with your string attribute name that represents the camera name to animate to.
const cameraAnimationTime = "CamAnimationTime"; //rename with your number attribute name that represents the time in seconds for the animation to complete.
const orbitTargetNodeName = "CamTarget"; //rename with your string attribute name that represents the target node name to look at during the animation
const logDebug = false; //set to true to log debug information to the console

// Function to find a camera node hierarchically
async function findNode(cameraName, nodeType) {
  const node = api.scene.findNode({
    from: api.enableApi("player").stageId,
    hierarchical: true,
    type: nodeType,
    name: cameraName,
  });
  return node;
}

// Function to clone a node, which will be used to duplicate the default camera node, and set it as the active camera
function cloneNode({ sourceId, newName }) {
  const node = api.scene.get({
    from: api.enableApi("player").stageId,
    id: sourceId,
  });
  const { id: _id, children: _children, ...props } = node;
  const newNodeId = api.scene.addNode(
    {
      ...props,
      name: newName,
      plugs: Object.fromEntries(
        Object.entries(node.plugs).map(([k, v]) => [
          k,
          v.map((op) => {
            const { id: _id, ...props } = op;
            return props;
          }),
        ])
      ),
    },
    api.instanceId
  );
  return api.scene.get({ id: newNodeId });
}

// Function to set the initial player to the default value of the camera attribute
async function setInitialCamera() {
  const config = api.configurator.getConfiguration();
  const selectedCamera = config[cameraAttributeName];
  const cameraNode = await findNode(selectedCamera, "Camera");
  if (!cameraNode) {
    console.error(`Initial Camera "${selectedCamera}" not found.`);
    return;
  }
  const clonedCameraNode = await cloneNode({
    sourceId: cameraNode,
    newName: "Initial Camera",
  });
  api.setActiveCamera(clonedCameraNode.id);
  let nodeRotate = false;
  if (clonedCameraNode.plugs.Camera[0]?.controlsMode === "nodeTurntable") {
    nodeRotate = true;
    if (logDebug) {
      console.log("Camera control mode is set to Node/Turntable");
    }
  }
  api.cache.nodeRotate = nodeRotate;
  api.cache.clonedCameraNode = clonedCameraNode;
  const targetNode = await api.scene.get({
    from: api.enableApi("player").stageId,
    hierarchical: true,
    id: api.cache.targetNodeId,
  });
  api.cache.targetNodeRotation = targetNode.plugs.Transform[0]?.rotation;
  console.log("Target Node Rotation:", api.cache.targetNodeRotation);
}

// Function to animate the camera
async function animateCamera(targetCameraId, cameraTargetId, animationTime) {
  let { Vector3, Quaternion, Euler } = api.THREE;
  const rotateOrder = "ZYX";

  const targetCamera = await api.scene.get({ id: targetCameraId });
  if (!targetCamera) {
    console.error(`Target camera not found with ID: ${targetCameraId}`);
    return;
  }

  const cameraTarget = await api.scene.get({ id: cameraTargetId });
  if (!cameraTarget) {
    console.error(
      `Camera orbit target not found with ID: ${cameraTargetId}. Using the origin (0, 0, 0) instead.`
    );
  }

  const targetPosition = targetCamera.plugs.Transform[0]?.translation || {
    x: 0,
    y: 0,
    z: 0,
  };
  const targetRotation = targetCamera.plugs.Transform[0]?.rotation || {
    x: 0,
    y: 0,
    z: 0,
  };

  const orbitPosition = cameraTarget.plugs.Transform[0]?.translation || {
    x: 0,
    y: 0,
    z: 0,
  };

  const targetQuaternion = new Quaternion().setFromEuler(
    new Euler(
      (Math.PI * targetRotation.x) / 180,
      (Math.PI * targetRotation.y) / 180,
      (Math.PI * targetRotation.z) / 180,
      rotateOrder
    )
  );

  // Get the current camera's position and rotation
  const currentPosition = api.camera.getPosition();
  const currentQuaternion = api.camera.getQuaternion();

  const currentOrbitNodeRotation = cameraTarget.plugs.Transform[0]
    ?.rotation || {
    x: 0,
    y: 0,
    z: 0,
  };

  if (logDebug) {
    console.log("Animating camera...");
    console.log("Current Camera Position:", currentPosition);
    console.log("Target Camera Position:", targetPosition);
    console.log("Target Camera Rotation:", targetRotation);
    console.log("Orbit Position:", orbitPosition);
    console.log("Target Quaternion:", targetQuaternion);
    console.log("Current Orbit Node Rotation:", currentOrbitNodeRotation);
    console.log("Target Orbit Node Rotation:", api.cache.targetNodeRotation);
  }

  // Calculate the actual current position vector relative to orbit
  const currentVector = new Vector3(
    currentPosition.x - orbitPosition.x,
    currentPosition.y - orbitPosition.y,
    currentPosition.z - orbitPosition.z
  );

  // Calculate the actual target position vector relative to orbit
  const targetVector = new Vector3(
    targetPosition.x - orbitPosition.x,
    targetPosition.y - orbitPosition.y,
    targetPosition.z - orbitPosition.z
  );

  // Calculate what the starting position vector should be in the current camera's coordinate system
  // We do this by applying the inverse of the current quaternion to the actual current vector
  const inverseCurrentQuat = currentQuaternion.clone().inverse();
  const actualStartPosition = currentVector
    .clone()
    .applyQuaternion(inverseCurrentQuat);

  // Calculate what the target position vector should be in the target camera's coordinate system
  // We do this by applying the inverse of the target quaternion to the actual target vector
  const inverseTargetQuat = targetQuaternion.clone().inverse();
  const actualTargetPosition = targetVector
    .clone()
    .applyQuaternion(inverseTargetQuat);

  // Easing function for smooth animation
  const easeInOut = (x) =>
    x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;

  // Animation loop
  let start;
  function step(timestamp) {
    if (!start) start = timestamp;
    const elapsed = timestamp - start;
    const elapsedPercent = Math.min(elapsed / animationTime, 1);
    const animPercent = easeInOut(elapsedPercent);

    // Start from the actual current position and end at the actual target position
    const interpolatedPosition = actualStartPosition
      .clone()
      .lerp(actualTargetPosition, animPercent);

    const interpolatedQuat = currentQuaternion
      .clone()
      .slerp(targetQuaternion, animPercent);
    const rotatedPosition = interpolatedPosition
      .clone()
      .applyQuaternion(interpolatedQuat);
    const worldPos = orbitPosition.clone().add(rotatedPosition);
    api.camera.setPosition(worldPos);

    api.camera.setQuaternion(interpolatedQuat);

    // If the camera control mode is set to Node/Turntable, we need to interpolate the orbit node rotation
    if (api.cache.nodeRotate) {
      // Convert current and target rotations to quaternions
      const currentOrbitQuat = new Quaternion().setFromEuler(
        new Euler(
          (Math.PI * currentOrbitNodeRotation.x) / 180,
          (Math.PI * currentOrbitNodeRotation.y) / 180,
          (Math.PI * currentOrbitNodeRotation.z) / 180,
          rotateOrder
        )
      );

      const targetOrbitQuat = new Quaternion().setFromEuler(
        new Euler(
          (Math.PI * api.cache.targetNodeRotation.x) / 180,
          (Math.PI * api.cache.targetNodeRotation.y) / 180,
          (Math.PI * api.cache.targetNodeRotation.z) / 180,
          rotateOrder
        )
      );

      // Use quaternion slerp for smooth interpolation without gimbal lock
      const interpolatedOrbitQuat = currentOrbitQuat
        .clone()
        .slerp(targetOrbitQuat, animPercent);

      // Convert back to Euler angles for the scene
      const interpolatedOrbitEuler = new Euler().setFromQuaternion(
        interpolatedOrbitQuat,
        rotateOrder
      );
      const interpolatedOrbitRotation = {
        x: (interpolatedOrbitEuler.x * 180) / Math.PI,
        y: (interpolatedOrbitEuler.y * 180) / Math.PI,
        z: (interpolatedOrbitEuler.z * 180) / Math.PI,
      };

      api.scene.set(
        {
          from: api.enableApi("player").stageId,
          hierarchical: true,
          id: api.cache.targetNodeId,
          plug: "Transform",
          property: "rotation",
        },
        interpolatedOrbitRotation
      );
    }

    if (elapsed < animationTime) {
      window.requestAnimationFrame(step);
    } else {
      if (logDebug) {
        console.log("Camera animation complete.");
      }
    }
  }
  requestAnimationFrame(step);
}

// Main script
api.evaluate().then(async () => {
  if (logDebug) {
    console.log("Initializing script...");
  }

  // Retrieve configuration
  const config = api.configurator.getConfiguration();
  const selectedCamera = config[cameraAttributeName];

  let animationTime = parseFloat(config[cameraAnimationTime]) || 1; // Default to 1 second

  if (logDebug) {
    console.log("Selected Camera:", selectedCamera);
    console.log(`Animation Time: ${animationTime} seconds`);
  }

  // Convert time to milliseconds
  animationTime *= 1000;

  // Find the camera node hierarchically
  const cameraNode = await findNode(selectedCamera, "Camera");
  if (!cameraNode) {
    console.error(`Camera "${selectedCamera}" not found.`);
    return;
  }

  // Find the target node
  const targetNode = await findNode(orbitTargetNodeName);
  if (!targetNode) {
    console.error(
      `Camera Orbit Target node "${orbitTargetNodeName}" not found. Using the origin (0, 0, 0) instead.`
    );
  }
  if (!api.cache.targetNodeId) {
    api.cache.targetNodeId = targetNode;
  }

  if (logDebug) {
    console.log("Found Camera Node:", selectedCamera, "with id", cameraNode);
    if (targetNode) {
      console.log(
        "Found Camera Orbit Target Node:",
        orbitTargetNodeName,
        "with id",
        targetNode
      );
    }
  }

  // Set the initial camera on first load
  if (!api.cache.prevCamera) {
    api.cache.prevCamera = selectedCamera;
    if (logDebug) {
      console.log("Initial camera load");
    }
    await setInitialCamera();
    return;
  }
  // Avoid running the animation if the camera attribute has not changed
  if (api.cache.prevCamera === selectedCamera) {
    if (logDebug) {
      console.log("Selected camera is the same as the previous camera");
    }
    return;
  }
  api.cache.prevCamera = selectedCamera;

  // Animate the camera
  await animateCamera(cameraNode, targetNode, animationTime);
});

Limitations

  • The animation curve cannot be controlled without editing the script, but this requires some understanding of the math involved in the easing function. This can be done by editing the following line of code:

    const easeInOut = (x) =>
        x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
  • Only the position and rotation of the cameras will be used during the animation transition. This means that if you have different cameras with different focal lengths, different orbit modes, or different constraints, this information will not currently get applied.

    • For constraints, you can use the relative mode instead of world mode for Longitude constraints in particular, which would be more general regardless of the camera orientation, but feel free to experiement with different settings to see what works best.

Last updated

Was this helpful?