Search code examples
javascriptthree.jscannon.js

Three.js keep camera behind object


The short version: How can one make a camera follow an object controlled by physics within a Three.js scene?

The long version: I'm working on a Three.js scene in which the W,A,S,D keys move a sphere along a plane. So far, however, I haven't figured out how to make the camera follow behind the sphere.

In the example below, the camera follows the sphere perfectly if one only presses the W key. However, if one presses A or D, the sphere starts to turn, and the camera is no longer behind the ball. If the sphere starts to turn, I want the camera to turn with it, so the camera is always following just behind the sphere, and is always a constant distance from the sphere. As users continue to press W, the ball will continue rolling forward relative to the camera.

In a previous scene [demo], I was able to implement this behavior by creating the sphere, adding that sphere to a group, then using the following bit of code each frame:

var relativeCameraOffset = new THREE.Vector3(0,50,200);
var cameraOffset = relativeCameraOffset.applyMatrix4(sphereGroup.matrixWorld);
camera.position.x = cameraOffset.x;
camera.position.y = cameraOffset.y;
camera.position.z = cameraOffset.z;
camera.lookAt(sphereGroup.position);

The key in the demo above was to rotate the sphere while keeping the sphereGroup unrotated, so I could compute the cameraOffset on the un-rotated sphereGroup.

In the demo below, the sphere's position is controlled by the Cannon.js physics library, which translates and rotates the sphere as forces are applied to the body. Does anyone know how I can make the camera follow behind the sphere in the scene below?

/**
* Generate a scene object with a background color
**/

function getScene() {
  var scene = new THREE.Scene();
  scene.background = new THREE.Color(0x111111);
  return scene;
}

/**
* Generate the camera to be used in the scene. Camera args:
*   [0] field of view: identifies the portion of the scene
*     visible at any time (in degrees)
*   [1] aspect ratio: identifies the aspect ratio of the
*     scene in width/height
*   [2] near clipping plane: objects closer than the near
*     clipping plane are culled from the scene
*   [3] far clipping plane: objects farther than the far
*     clipping plane are culled from the scene
**/

function getCamera() {
  var aspectRatio = window.innerWidth / window.innerHeight;
  var camera = new THREE.PerspectiveCamera(75, aspectRatio, 0.1, 10000);
  camera.position.set(0, 2000, -5000);
  camera.lookAt(scene.position);  
  return camera;
}

/**
* Generate the light to be used in the scene. Light args:
*   [0]: Hexadecimal color of the light
*   [1]: Numeric value of the light's strength/intensity
*   [2]: The distance from the light where the intensity is 0
* @param {obj} scene: the current scene object
**/

function getLight(scene) {
  var light = new THREE.PointLight( 0xffffff, 0.6, 0, 0 )
  light.position.set( -2000, 1000, -2100 );
  scene.add( light );

  var light = new THREE.PointLight( 0xffffff, 0.15, 0, 0 )
  light.position.set( -190, 275, -1801 );
  light.castShadow = true;
  scene.add( light );

  // create some ambient light for the scene
  var ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
  scene.add(ambientLight);
  return light;
}

/**
* Generate the renderer to be used in the scene
**/

function getRenderer() {
  // Create the canvas with a renderer
  var renderer = new THREE.WebGLRenderer({antialias: true});
  // Add support for retina displays
  renderer.setPixelRatio(window.devicePixelRatio);
  // Specify the size of the canvas
  renderer.setSize(window.innerWidth, window.innerHeight);
  // Enable shadows
  renderer.shadowMap.enabled = true;
  // Specify the shadow type; default = THREE.PCFShadowMap
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  // Add the canvas to the DOM
  document.body.appendChild(renderer.domElement);
  return renderer;
}

/**
* Generate the controls to be used in the scene
* @param {obj} camera: the three.js camera for the scene
* @param {obj} renderer: the three.js renderer for the scene
**/

function getControls(camera, renderer) {
  var controls = new THREE.TrackballControls(camera, renderer.domElement);
  controls.zoomSpeed = 0.4;
  controls.panSpeed = 0.4;
  return controls;
}

/**
* Get stats
**/

function getStats() {
  stats = new Stats();
  stats.domElement.style.position = 'absolute';
  stats.domElement.style.top = '0px';
  stats.domElement.style.right = '0px';
  document.body.appendChild( stats.domElement );
  return stats;
}

/**
* Get grass
**/

function getGrass() {
  var texture = loader.load('http://4.bp.blogspot.com/-JiJEc7lH1Is/UHJs3kn261I/AAAAAAAADYA/gQRAxHK2q_w/s1600/tileable_old_school_video_game_grass.jpg');
  texture.wrapS = texture.wrapT = THREE.RepeatWrapping; 
  texture.repeat.set(10, 10);
  var material = new THREE.MeshLambertMaterial({
    map: texture,
    side: THREE.DoubleSide,
  });
  return material;
}

function getPlanes(scene, loader) {
  var planes = [];
  var material = getGrass();
  [ [4000, 2000, 0, 0, -1000, 0] ].map(function(p) {
    var geometry = new THREE.PlaneGeometry(p[0], p[1]);
    var plane = new THREE.Mesh(geometry, material);
    plane.position.x = p[2];
    plane.position.y = p[3];
    plane.position.z = p[4];
    plane.rotation.y = p[5];
    plane.rotation.x = Math.PI / 2;
    plane.receiveShadow = true;
    planes.push(plane);
    scene.add(plane);
  })
  return planes;
}

/**
* Add background
**/

function getBackground(scene, loader) {
  var imagePrefix = 'sky-parts/';
  var directions  = ['right', 'left', 'top', 'bottom', 'front', 'back'];
  var imageSuffix = '.bmp';
  var geometry = new THREE.BoxGeometry( 4000, 4000, 4000 ); 
  // Add each of the images for the background cube
  var materialArray = [];
  for (var i = 0; i < 6; i++)
    materialArray.push( new THREE.MeshBasicMaterial({
      //map: loader.load(imagePrefix + directions[i] + imageSuffix),
      color: 0xff0000,
      side: THREE.BackSide
    }));
  var sky = new THREE.Mesh( geometry, materialArray );
  scene.add(sky);
  return sky;
}

/**
* Add a character
**/

function getSphere(scene) {
  var geometry = new THREE.SphereGeometry( 30, 12, 9 );
  var material = new THREE.MeshPhongMaterial({
    color: 0xd0901d,
    emissive: 0xaa0000,
    side: THREE.DoubleSide,
    flatShading: true
  });
  var sphere = new THREE.Mesh( geometry, material );
  // allow the sphere to cast a shadow
  sphere.castShadow = true;
  sphere.receiveShadow = false;
  // create a group for translations and rotations
  var sphereGroup = new THREE.Group();
  sphereGroup.add(sphere)
  sphereGroup.castShadow = true;
  sphereGroup.receiveShadow = false;
  scene.add(sphereGroup);
  return [sphere, sphereGroup];
}

/**
* Initialize physics engine
**/

function getPhysics() {
  world = new CANNON.World();
  world.gravity.set(0, -400, 0); // earth = -9.82 m/s
  world.broadphase = new CANNON.NaiveBroadphase();
  world.broadphase.useBoundingBoxes = true;
  var solver = new CANNON.GSSolver();
  solver.iterations = 7;
  solver.tolerance = 0.1;
  world.solver = solver;
  world.quatNormalizeSkip = 0;
  world.quatNormalizeFast = false;
  world.defaultContactMaterial.contactEquationStiffness = 1e9;
  world.defaultContactMaterial.contactEquationRelaxation = 4;
  return world;
}

/**
* Generate the materials to be used for contacts
**/

function getPhysicsMaterial() {
  var physicsMaterial = new CANNON.Material('slipperyMaterial');
  var physicsContactMaterial = new CANNON.ContactMaterial(
      physicsMaterial, physicsMaterial, 0.0, 0.3)
  world.addContactMaterial(physicsContactMaterial);
  return physicsMaterial;
}

/**
* Add objects to the world
**/

function addObjectPhysics() {
  addFloorPhysics()
  addSpherePhysics()
}

function addFloorPhysics() {
  floors.map(function(floor) {
    var q = floor.quaternion;
    floorBody = new CANNON.Body({
      mass: 0, // mass = 0 makes the body static
      material: physicsMaterial,
      shape: new CANNON.Plane(),
      quaternion: new CANNON.Quaternion(-q._x, q._y, q._z, q._w)
    });      
    world.addBody(floorBody);
  })
}

function addSpherePhysics() {
  sphereBody = new CANNON.Body({
    mass: 1,
    material: physicsMaterial,
    shape: new CANNON.Sphere(30),
    linearDamping: 0.5,
    position: new CANNON.Vec3(1000, 500, -2000)
  });
  world.addBody(sphereBody);
}

/**
* Store all currently pressed keys & handle window resize
**/

function addListeners() {
  window.addEventListener('keydown', function(e) {
    pressed[e.key.toUpperCase()] = true;
  })
  window.addEventListener('keyup', function(e) {
    pressed[e.key.toUpperCase()] = false;
  })
  window.addEventListener('resize', function(e) {
    windowHalfX = window.innerWidth / 2;
    windowHalfY = window.innerHeight / 2;
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
    if (typeof(controls) != 'undefined') controls.handleResize();
  })
}

/**
* Update the sphere's position
**/

function moveSphere() {
  var delta = clock.getDelta(); // seconds
  var moveDistance = 500 * delta; // n pixels per second
  var rotateAngle = Math.PI / 2 * delta; // 90 deg per second

  // move forwards, backwards, left, or right
  if (pressed['W'] || pressed['ARROWUP']) {
    sphereBody.velocity.z += moveDistance;
  }
  if (pressed['S'] || pressed['ARROWDOWN']) {
    sphereBody.velocity.z -= moveDistance;
  }
  if (pressed['A'] || pressed['ARROWLEFT']) {
    sphereBody.velocity.x += moveDistance;
  }
  if (pressed['D'] || pressed['ARROWRIGHT']) {
    sphereBody.velocity.x -= moveDistance;
  }
}

/**
* Follow the sphere
**/

function moveCamera() {
  camera.position.x = sphereBody.position.x + 0;
  camera.position.y = sphereBody.position.y + 50;
  camera.position.z = sphereBody.position.z + -200;
  camera.lookAt(sphereGroup.position);
}

function updatePhysics() {
  world.step(1/60);
  sphereGroup.position.copy(sphereBody.position);
  sphereGroup.quaternion.copy(sphereBody.quaternion);
}

// Render loop
function render() {
  requestAnimationFrame(render);
  renderer.render(scene, camera);
  moveSphere();
  updatePhysics();
  if (typeof(controls) === 'undefined') moveCamera();
  if (typeof(controls) !== 'undefined') controls.update();
  if (typeof(stats) !== 'undefined') stats.update();
};

// state
var pressed = {};
var clock = new THREE.Clock();

// globals
var scene = getScene();
var camera = getCamera();
var light = getLight(scene);
var renderer = getRenderer();
var world = getPhysics();
var physicsMaterial = getPhysicsMaterial();
//var stats = getStats();
//var controls = getControls(camera, renderer);

// global body references
var sphereBody, floorBody;

// add meshes
var loader = new THREE.TextureLoader();
var floors = getPlanes(scene, loader);
var background = getBackground(scene, loader);
var sphereData = getSphere(scene);
var sphere = sphereData[0];
var sphereGroup = sphereData[1];

addObjectPhysics();
addListeners();
render();
body { margin: 0; overflow: hidden; }
canvas { width: 100%; height: 100%; }
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/88/three.min.js'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/cannon.js/0.6.2/cannon.js'></script>

Answers to comment questions

@jparimaa I think the most intuitive implementation would make W add forward momentum, S add backward momentum, and A and D rotate the camera around the ball. Is that possible?

@HariV The controls you link to are the ones I used in the demo without physics above. Is it possible to get that logic working with physics?


Solution

  • I think it's most intuitive for users if the W key always moves the ball "forward" relative to the camera

    One option would be to calculate the direction between the ball and the camera and add velocity to that direction. In this case if you push the ball forward then you could rotate the camera without it affecting the velocity of the ball. Only after you press W/S after the rotation it would change the direction. I'm not sure if that is what you want but maybe this will give you some ideas.

    I tried the following code (rotation is global variable initialized to 0)

    function moveSphere() {
        var delta = clock.getDelta(); // seconds
        var moveDistance = 500 * delta; // n pixels per second
        var dir = new THREE.Vector3(sphereBody.position.x, sphereBody.position.y, sphereBody.position.z);
        dir.sub(camera.position).normalize(); // direction vector between the camera and the ball
        if (pressed['W'] || pressed['ARROWUP']) {
            sphereBody.velocity.x += moveDistance * dir.x;
            sphereBody.velocity.z += moveDistance * dir.z;
        }
        if (pressed['S'] || pressed['ARROWDOWN']) {
            sphereBody.velocity.x -= moveDistance * dir.x;
            sphereBody.velocity.z -= moveDistance * dir.z;
        }
    }
    
    function moveCamera() {
        var delta = clock.getDelta();
        var sensitivity = 150;
        var rotateAngle = Math.PI / 2 * delta * sensitivity;
        if (pressed['A'] || pressed['ARROWLEFT']) {
            rotation -= rotateAngle;
        }
        if (pressed['D'] || pressed['ARROWRIGHT']) {
            rotation += rotateAngle;
        }
        var rotZ = Math.cos(rotation)
        var rotX = Math.sin(rotation)
        var distance = 200;
        camera.position.x = sphereBody.position.x - (distance * rotX);
        camera.position.y = sphereBody.position.y + 50;
        camera.position.z = sphereBody.position.z - (distance * rotZ);
        camera.lookAt(sphereGroup.position);
    }