2

I have OrbitControls update target via: orbitControls.target = target.position each frame.

However, if the target is moving, and I click and drag to orbit to a position where the object is moving toward the camera, the object ends up going "through" the camera because the camera ceases to move along with the target.

Is there a way to have the orbitControls keep a minimum distance from the target? I've tried minDistance but that doesn't seem to work when I click and drag.

K2xL
  • 9,730
  • 18
  • 64
  • 101

2 Answers2

1

Figured it out a way (but still has issues):

orbitControls.target = player.mesh.position;
        // find distance from player to camera
        const dist = camera.position.distanceTo(player.mesh.position);
        // if distance is less than minimum distance, set the camera position to minimum distance
        if (dist < MINIMUM_CAMERA_DISTANCE) {
                  const dir = camera.position.clone().sub(player.mesh.position).normalize();
  
           camera.position.copy(player.mesh.position.clone().add(dir.multiplyScalar(MINIMUM_CAMERA_DISTANCE)));
        }
K2xL
  • 9,730
  • 18
  • 64
  • 101
1

Your answer has nice side effects, in moving "around" the object instead of stopping the camera right at the minimum distance (this could even be a feature in some cases, if the whole thing worked right), but having the same problem as you do, I see what you meant when saying that your way still has issues, with the camera losing its direction when zooming out from the minimum distance zone.

Here is another - probably better - way, with an alternative being presented after it below. Add a change event listener to your controls, something similar to - adapt names or variables where necessary:

controls.addEventListener("change", function(e)
{
  if (camera.position.distanceTo(targetmesh.position) < mincam)
  {
    camera.translateZ(maxcam - camera.position.distanceTo(targetmesh.position));
    var scaledobject = targetmesh.clone();
    scaledobject.scale.setScalar(mincam);
    scaledobject.updateMatrixWorld(true);
    var cameradirection = new THREE.Vector3();
    camera.getWorldDirection(cameradirection);
    cameradirection.normalize();
    var cameraorigin = new THREE.Vector3();
    camera.getWorldPosition(cameraorigin);
    var raycaster = new THREE.Raycaster(cameraorigin, cameradirection);
    var intersects = raycaster.intersectObjects([scaledobject], false);
    camera.translateZ(intersects.length ? - intersects[0].distance : mincam - maxcam);
  };
  renderer.render(scene, camera);
});

Obviously, controls is your "orbitControls", targetmesh is your "player.mesh" equivalent, mincam and maxcam are your "MINIMUM_CAMERA_DISTANCE" and supposed "MAXIMUM_CAMERA_DISTANCE" variables, with the latter chosen conveniently so that the camera is moved as far as possible from the minimum point.

What the above does is to check if the distance to the object is less than the set minimum on every change event of the controls, and if that's the case, move the camera back (positive Z translation in local space, as explained in other SO answers) sufficiently so that a ray cast from it along its direction can safely intersect (or not) the scaled clone of your original object to yield the intersect point. Since the clone is scaled to the minimum camera distance "boundary", it will include precisely the zone you don't want the camera to travel into, both in and out of the original object, irrespective of its shape. As a result, the intersect point, if it exists, will be the point where you'd want the camera to stop, as it's both at the minimum distance from the original object due to the clone scaling, and on the camera direction / path due to the ray casting from the camera along that direction.

A little care is needed if the target object / mesh has other children, but you can set the recursive parameter of the .intersectObjects() method to true in such a case, if required. Maybe not extremely efficient either, since the clone is created every time the condition is true, but that can easily be adjusted to happen just once, outside the event listener, if other things like its position are always correlated to the ones of the original object. Or, you could .dispose() things when and if needed.

Note: In this version of the code, the camera position is initially set to its maximum allowed distance from the object (i.e. maxcam) so that a ray is cast from there without its origin being too close to a potential intersection, then depending on whether a point of intersection exists, the position is set, one way or another, to the minimum allowed distance (i.e. mincam) in both branches of the .translateZ() conditional.


After some more thought about it, I realized that there are actually 3 points of interest in the entire problem here, i.e. the object position, the camera position and the valid camera position according to the minimum distance to the object, which form a simple planar triangle in space. Two of the sides of the triangle are known (the distance from camera to object and the minimum distance between those), and the angle between the inverted / negated camera direction and the camera to object distance line can be retrieved via the .angleTo() method of Vector3. We just need to find the third side of the triangle, aka the current to valid camera position distance, or how much we need to offset the camera along its negated direction in order to be placed at the minimum distance from the object.

Therefore, the alternative solution is basically to transform a 3D problem into a 2D one and solve it, being lighter on resources since it only deals with values and not geometry or meshes, at the cost of making the "forbidden area" a sphere instead of following the object's shape like above. The controls' change event listener thus becomes:

controls.addEventListener("change", function(e)
{
  if (camera.position.distanceTo(targetmesh.position) < mincam)
  {
    var cameradirection = new THREE.Vector3();
    camera.getWorldDirection(cameradirection);
    cameradirection.negate().normalize();
    var distancedirection = new THREE.Vector3();
    distancedirection.subVectors(targetmesh.getWorldPosition(new THREE.Vector3()), camera.getWorldPosition(new THREE.Vector3()));
    distancedirection.normalize();
    var positionangle = distancedirection.angleTo(cameradirection);
    if (Math.abs(Math.sin(positionangle)) < Number.EPSILON) {camera.translateZ(mincam - camera.position.distanceTo(targetmesh.position));}
    else {camera.translateZ(mincam * Math.sin(Math.PI - positionangle - Math.asin(camera.position.distanceTo(targetmesh.position) * Math.sin(positionangle) / mincam)) / Math.sin(positionangle));};
  };  
  renderer.render(scene, camera);
});

You probably know that already, but for anyone else reading this, getting the direction vector between two points in space, getting the angle between two direction vectors, and getting the needed angles and sides from a triangle were handy in solving this. The .translateZ() formula in the else action is just the result of merging the needed parts after applying the law of sines for the triangle. The special case of the angle between the two directions being a multiple of PI and causing a division by 0 is handled in the last if statement.

Note: In both variants of the code, if you need the controls to update, you can add controls.update(); in a wheel event listener attached to the parent element of the renderer or in the animation loop (i.e. outside the change event listener, but after it!), considering that in most controls, adding it in their change event throws an infinite loop error. The only control that doesn't throw that error when using .update() in the change event listener is the ArcballControls, which incidentally I also use for the most part.

Yin Cognyto
  • 986
  • 1
  • 10
  • 22