12
votes

I'm trying to find the visible size of a sphere in pixels, after projection to screen space. The sphere is centered at the origin with the camera looking right at it. Thus the projected sphere should be a perfect circle in two dimensions. I am aware of this 1 existing question. However, the formula given there doesn't seem to produce the result I want. It is too small by a few percent. I assume this is because it is not correctly taking perspective into account. After projecting to screen space you do not see half the sphere but significantly less, due to perspective foreshortening (you see just a cap of the sphere instead of the full hemisphere 2).

How can I derive an exact 2D bounding circle?

4

4 Answers

22
votes

Indeed, with a perspective projection you need to compute the height of the sphere "horizon" from the eye / center of the camera (this "horizon" is determined by rays from the eye tangent to the sphere).

Notations:

Notations

d: distance between the eye and the center of the sphere
r: radius of the sphere
l: distance between the eye and a point on the sphere "horizon", l = sqrt(d^2 - r^2)
h: height / radius of the sphere "horizon"
theta: (half-)angle of the "horizon" cone from the eye
phi: complementary angle of theta

h / l = cos(phi)

but:

r / d = cos(phi)

so, in the end:

h = l * r / d = sqrt(d^2 - r^2) * r / d

Then once you have h, simply apply the standard formula (the one from the question you linked) to get the projected radius pr in the normalized viewport:

pr = cot(fovy / 2) * h / z

with z the distance from the eye to the plane of the sphere "horizon":

z = l * cos(theta) = sqrt(d^2 - r^2) * h / r

so:

pr = cot(fovy / 2) * r / sqrt(d^2 - r^2)

And finally, multiply pr by height / 2 to get the actual screen radius in pixels.

What follows is a small demo done with three.js. The sphere distance, radius and the vertical field of view of the camera can be changed by using respectively the n / f, m / p and s / w pairs of keys. A yellow line segment rendered in screen-space shows the result of the computation of the radius of the sphere in screen-space. This computation is done in the function computeProjectedRadius().

Projected sphere demo in three.js

projected-sphere.js:

"use strict";

function computeProjectedRadius(fovy, d, r) {
  var fov;

  fov = fovy / 2 * Math.PI / 180.0;

//return 1.0 / Math.tan(fov) * r / d; // Wrong
  return 1.0 / Math.tan(fov) * r / Math.sqrt(d * d - r * r); // Right
}

function Demo() {
  this.width = 0;
  this.height = 0;

  this.scene = null;
  this.mesh = null;
  this.camera = null;

  this.screenLine = null;
  this.screenScene = null;
  this.screenCamera = null;

  this.renderer = null;

  this.fovy = 60.0;
  this.d = 10.0;
  this.r = 1.0;
  this.pr = computeProjectedRadius(this.fovy, this.d, this.r);
}

Demo.prototype.init = function() {
  var aspect;
  var light;
  var container;

  this.width = window.innerWidth;
  this.height = window.innerHeight;

  // World scene
  aspect = this.width / this.height;
  this.camera = new THREE.PerspectiveCamera(this.fovy, aspect, 0.1, 100.0);

  this.scene = new THREE.Scene();
  this.scene.add(THREE.AmbientLight(0x1F1F1F));

  light = new THREE.DirectionalLight(0xFFFFFF);
  light.position.set(1.0, 1.0, 1.0).normalize();
  this.scene.add(light);

  // Screen scene
  this.screenCamera = new THREE.OrthographicCamera(-aspect, aspect,
                                                   -1.0, 1.0,
                                                   0.1, 100.0);
  this.screenScene = new THREE.Scene();

  this.updateScenes();

  this.renderer = new THREE.WebGLRenderer({
    antialias: true
  });
  this.renderer.setSize(this.width, this.height);
  this.renderer.domElement.style.position = "relative";
  this.renderer.autoClear = false;

  container = document.createElement('div');
  container.appendChild(this.renderer.domElement);
  document.body.appendChild(container);
}

Demo.prototype.render = function() {
  this.renderer.clear();
  this.renderer.setViewport(0, 0, this.width, this.height);
  this.renderer.render(this.scene, this.camera);
  this.renderer.render(this.screenScene, this.screenCamera);
}

Demo.prototype.updateScenes = function() {
  var geometry;

  this.camera.fov = this.fovy;
  this.camera.updateProjectionMatrix();

  if (this.mesh) {
    this.scene.remove(this.mesh);
  }

  this.mesh = new THREE.Mesh(
    new THREE.SphereGeometry(this.r, 16, 16),
    new THREE.MeshLambertMaterial({
      color: 0xFF0000
    })
  );
  this.mesh.position.z = -this.d;
  this.scene.add(this.mesh);

  this.pr = computeProjectedRadius(this.fovy, this.d, this.r);

  if (this.screenLine) {
    this.screenScene.remove(this.screenLine);
  }

  geometry = new THREE.Geometry();
  geometry.vertices.push(new THREE.Vector3(0.0, 0.0, -1.0));
  geometry.vertices.push(new THREE.Vector3(0.0, -this.pr, -1.0));

  this.screenLine = new THREE.Line(
    geometry,
    new THREE.LineBasicMaterial({
      color: 0xFFFF00
    })
  );

  this.screenScene = new THREE.Scene();
  this.screenScene.add(this.screenLine);
}

Demo.prototype.onKeyDown = function(event) {
  console.log(event.keyCode)
  switch (event.keyCode) {
    case 78: // 'n'
      this.d /= 1.1;
      this.updateScenes();
      break;
    case 70: // 'f'
      this.d *= 1.1;
      this.updateScenes();
      break;
    case 77: // 'm'
      this.r /= 1.1;
      this.updateScenes();
      break;
    case 80: // 'p'
      this.r *= 1.1;
      this.updateScenes();
      break;
    case 83: // 's'
      this.fovy /= 1.1;
      this.updateScenes();
      break;
    case 87: // 'w'
      this.fovy *= 1.1;
      this.updateScenes();
      break;
  }
}

Demo.prototype.onResize = function(event) {
  var aspect;

  this.width = window.innerWidth;
  this.height = window.innerHeight;

  this.renderer.setSize(this.width, this.height);

  aspect = this.width / this.height;
  this.camera.aspect = aspect;
  this.camera.updateProjectionMatrix();

  this.screenCamera.left = -aspect;
  this.screenCamera.right = aspect;
  this.screenCamera.updateProjectionMatrix();
}

function onLoad() {
  var demo;

  demo = new Demo();
  demo.init();

  function animationLoop() {
    demo.render();
    window.requestAnimationFrame(animationLoop);
  }

  function onResizeHandler(event) {
    demo.onResize(event);
  }

  function onKeyDownHandler(event) {
    demo.onKeyDown(event);
  }

  window.addEventListener('resize', onResizeHandler, false);
  window.addEventListener('keydown', onKeyDownHandler, false);
  window.requestAnimationFrame(animationLoop);
}

index.html:

<!DOCTYPE html>
<html>
  <head>
    <title>Projected sphere</title>
      <style>
        body {
            background-color: #000000;
        }
      </style>
      <script src="http://cdnjs.cloudflare.com/ajax/libs/three.js/r61/three.min.js"></script>
      <script src="projected-sphere.js"></script>
    </head>
    <body onLoad="onLoad()">
      <div id="container"></div>
    </body>
</html>
1
votes

Let the sphere have radius r and be seen at a distance d from the observer. The projection plane is at distance f from the observer.

The sphere is seen under the half angle asin(r/d), so the apparent radius is f.tan(asin(r/d)), which can be written as f . r / sqrt(d^2 - r^2). [The wrong formula being f . r / d.]

1
votes

The illustrated accepted answer above is excellent, but I needed a solution without knowing the field of view, just a matrix to transform between world and screen space, so I had to adapt the solution.

  1. Reusing some variable names from the other answer, calculate the start point of the spherical cap (the point where line h meets line d):

    capOffset = cos(asin(l / d)) * r
    capCenter = sphereCenter + ( sphereNormal * capOffset )
    

    where capCenter and sphereCenter are points in world space, and sphereNormal is a normalized vector pointing along d, from the sphere center towards the camera.

  2. Transform the point to screen space:

    capCenter2 = matrix.transform(capCenter)
    
  3. Add 1 (or any amount) to the x pixel coordinate:

    capCenter2.x += 1
    
  4. Transform it back to world space:

    capCenter2 = matrix.inverse().transform(capCenter2)
    
  5. Measure the distance between the original and new points in world space, and divide into the amount you added to get a scale factor:

    scaleFactor = 1 / capCenter.distance(capCenter2)
    
  6. Multiply that scale factor by the cap radius h to get the visible screen radius in pixels:

    screenRadius = h * scaleFactor
    
-1
votes

Old question, but a dirt simple hack:

Render a frame with two identical spheres: one at the middle of the screen, and one at a corner. Make sure the center of both spheres are equidistant from the camera.

Take a screenshot and load it in your favorite image editor.

Measure the extents of the centered sphere. This one should be a uniform, non-distorted circle. It's radius will be proportional to the solid angle of the sphere.

Measure the extents of the offset sphere. You'll need to get left and right radii. (Left will be greater than right.) Top and bottom don't change much assuming that you're using the vertical field of view to create your projection matrix.

Divide the left and right of the offset sphere by the radius of the centered sphere. Use these values to calculate the ratio of the distorted sphere's radii to that of the undistorted version.

Now, when you need the bounds of a sphere, interpolate between the centered radius and the distorted radius based on the projected center position of the sphere.

This reduces your calculation to basic arithmetic. It's not the "real math" way to do it, but it's fast and works pretty well. Especially if you're doing this for, say, calculating the bounds of a couple of thousand point lights to cull in a tiled renderer.

(I like the solid-angle version better.)