Cracking the three.js object fitting (to camera) nut
Tl;dr
This article explains both backstory and solution for fitting an object
within a Perspective camera in three.js library. In other words, how far
to pull back a camera on z
axis to get an object displayed as big as possible
without clipping.
Problem statement
Initially I’ve been using viewstl to display the 3D (STL) models on this blog.
But I wasn’t happy with the auto_resize
functionality. So much that I filed
an issue for viewstl. But the
solution offered (to use manual zoom
) also doesn’t fully work for me1.
And because during my research of this problem I stumbled upon Anthony Biondo’s Simple STL Viewer post, I decided to also roll out my own – simpler – STL viewer based on his2. After all, three.js has all the components needed. So all I needed was a bit of a glue.
When further researching the “fit camera to object” problem, I found some discussion on three.js that offered some solutions. But none of the solutions I’ve found really worked well for me.
Alas, I think I have finally cracked the nut. :)
Solution
There are essentially two problems with most of the algorithms I’ve found:
- They ignore the discrepancy between vertical FOV and horizontal FOV with
cameras that have
aspect
ratio different than1.0
- They take max of the three dimensions of the object and compute based on that
Since my simplified STL viewer has a single model always centered on (0, 0, 0)
,
I can basically take a shortcut that allows me to compute optimal fitting:
The z
distance of camera is half of z
-size of the bounding box, and then the bigger of:
- distance based on
horizontal FOV
andx
size of bounding box - distance based on
vertical FOV
andy
size of bounding box
Why does that work? Because I need to have the entire front size of the bounding
box (BB) visible. So the half of z
-size gets me from center to the front of the
BB, and then a bit of trigonometry gets me to the correct distance given aspect ratio
and size of the side.
It’s sort of hard to explain but much easier to draw:
I also tried documenting it in the code:
const fitCameraToCenteredObject = function (camera, object, offset, orbitControls ) {
const boundingBox = new THREE.Box3();
boundingBox.setFromObject( object );
var middle = new THREE.Vector3();
var size = new THREE.Vector3();
boundingBox.getSize(size);
// figure out how to fit the box in the view:
// 1. figure out horizontal FOV (on non-1.0 aspects)
// 2. figure out distance from the object in X and Y planes
// 3. select the max distance (to fit both sides in)
//
// The reason is as follows:
//
// Imagine a bounding box (BB) is centered at (0,0,0).
// Camera has vertical FOV (camera.fov) and horizontal FOV
// (camera.fov scaled by aspect, see fovh below)
//
// Therefore if you want to put the entire object into the field of view,
// you have to compute the distance as: z/2 (half of Z size of the BB
// protruding towards us) plus for both X and Y size of BB you have to
// figure out the distance created by the appropriate FOV.
//
// The FOV is always a triangle:
//
// (size/2)
// +--------+
// | /
// | /
// | /
// | F° /
// | /
// | /
// | /
// |/
//
// F° is half of respective FOV, so to compute the distance (the length
// of the straight line) one has to: `size/2 / Math.tan(F)`.
//
// FTR, from https://threejs.org/docs/#api/en/cameras/PerspectiveCamera
// the camera.fov is the vertical FOV.
const fov = camera.fov * ( Math.PI / 180 );
const fovh = 2*Math.atan(Math.tan(fov/2) * camera.aspect);
let dx = size.z / 2 + Math.abs( size.x / 2 / Math.tan( fovh / 2 ) );
let dy = size.z / 2 + Math.abs( size.y / 2 / Math.tan( fov / 2 ) );
let cameraZ = Math.max(dx, dy);
// offset the camera, if desired (to avoid filling the whole canvas)
if( offset !== undefined && offset !== 0 ) cameraZ *= offset;
camera.position.set( 0, 0, cameraZ );
// set the far plane of the camera so that it easily encompasses the whole object
const minZ = boundingBox.min.z;
const cameraToFarEdge = ( minZ < 0 ) ? -minZ + cameraZ : cameraZ - minZ;
camera.far = cameraToFarEdge * 3;
camera.updateProjectionMatrix();
if ( orbitControls !== undefined ) {
// set camera to rotate around the center
orbitControls.target = new THREE.Vector3(0, 0, 0);
// prevent camera from zooming out far enough to create far plane cutoff
orbitControls.maxDistance = cameraToFarEdge * 2;
}
};
With that, the auto-sizing works well (to my eyes anyway).
If you want to get inspired by the full stlviewer, check out the stlviewer-datatag.js.
Demo
Check out the auto-positioning of the same model in 4 different rotations (with bounding box drawn around the object)3:
First:
Second:
Third:
Fourth:
-
Because mobile version of this site uses different aspect ratio, which always throws the static sizing off on one of them. ↩
-
And partially on the viewstl lib, because there are some great ideas in the library. Like binding the light to the camera. ↩
-
I have to admit that the bounding box in the third example looks off, but I’m not enough of a three.js whiz to poke it with a stick to figure out what the problem is. ↩