Search code examples
javascriptmatrixthree.jswebglperspective

Three.js bounding rectangle of a plane relative to viewport and near plane clipping issue


This is a general WebGL issue but for the sake of clarity I'll be using three.js to demonstrate my problem here.

Let's say I have a plane and a perspective camera. I'm trying to get the bounding rectangle of the plane relative to the viewport/window. This is how I'm doing it so far:

  • First, get the modelViewProjectionMatrix by multiplying the camera projectionMatrix with the plane matrix.
  • Apply that modelViewProjectionMatrix to the plane 4 corners vertices.
  • Get the min/max values of the result and convert them back to viewport coordinates.

It works well until the plane gets clipped by the camera near plane (usually when using a high field of view), messing up my results.

Is there any way I can get correct values even if the camera near plane is clipping parts of my plane? Maybe by getting the intersection between the plane and the camera near plane?

Edit: One idea I can think of would be to get the two normalized vectors v1 and v2 as shown on this schema: intersections between a plane and the camera near plane schema. I'd then have to get the length of those vectors so that they go from the plane's corner to the intersection point (knowing the near plane Z position), but I'm still struggling on that last part.

Anyway, here's the three.js code and the according jsfiddle (uncomment line 109 to show erronate coordinates): https://jsfiddle.net/fbao9jp7/1/

let scene = new THREE.Scene();

let ww = window.innerWidth;
let wh = window.innerHeight;

// camera
const nearPlane = 0.1;
const farPlane = 200;
let camera = new THREE.PerspectiveCamera(45, ww / wh, nearPlane, farPlane);

scene.add(camera);

// renderer
let renderer = new THREE.WebGLRenderer();
renderer.setSize(ww, wh);
document.getElementById("canvas").appendChild(renderer.domElement);

// basic plane
let plane = new THREE.Mesh(
  new THREE.PlaneGeometry(0.75, 0.5),
  new THREE.MeshBasicMaterial({
    map: new THREE.TextureLoader().load('https://source.unsplash.com/EqFjlsOZULo/1280x720'),
    side: THREE.DoubleSide,
  })
);

scene.add(plane);

function displayBoundingRectangle() {
  camera.updateProjectionMatrix();

  // keep the plane at a constant position along Z axis based on camera FOV
  plane.position.z = -1 / (Math.tan((Math.PI / 180) * 0.5 * camera.fov) * 2.0);

  plane.updateMatrix();

  // get the plane model view projection matrix
  let modelViewProjectionMatrix = new THREE.Matrix4();
  modelViewProjectionMatrix = modelViewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, plane.matrix);

  let vertices = plane.geometry.vertices;

  // apply modelViewProjectionMatrix to our 4 vertices
  let projectedPoints = [];
  for (let i = 0; i < vertices.length; i++) {
    projectedPoints.push(vertices[i].applyMatrix4(modelViewProjectionMatrix));
  }

  // get our min/max values
  let minX = Infinity;
  let maxX = -Infinity;

  let minY = Infinity;
  let maxY = -Infinity;

  for (let i = 0; i < projectedPoints.length; i++) {
    let corner = projectedPoints[i];

    if (corner.x < minX) {
      minX = corner.x;
    }
    if (corner.x > maxX) {
      maxX = corner.x;
    }

    if (corner.y < minY) {
      minY = corner.y;
    }
    if (corner.y > maxY) {
      maxY = corner.y;
    }
  }

  // we have our four coordinates
  let worldBoundingRect = {
    top: maxY,
    right: maxX,
    bottom: minY,
    left: minX,
  };

  // convert coordinates from [-1, 1] to [0, 1]
  let screenBoundingRect = {
    top: 1 - (worldBoundingRect.top + 1) / 2,
    right: (worldBoundingRect.right + 1) / 2,
    bottom: 1 - (worldBoundingRect.bottom + 1) / 2,
    left: (worldBoundingRect.left + 1) / 2,
  };

  // add width and height
  screenBoundingRect.width = screenBoundingRect.right - screenBoundingRect.left;
  screenBoundingRect.height = screenBoundingRect.bottom - screenBoundingRect.top;

  var boundingRectEl = document.getElementById("plane-bounding-rectangle");

  // apply to our bounding rectangle div using window width and height
  boundingRectEl.style.top = screenBoundingRect.top * wh + "px";
  boundingRectEl.style.left = screenBoundingRect.left * ww + "px";
  boundingRectEl.style.height = screenBoundingRect.height * wh + "px";
  boundingRectEl.style.width = screenBoundingRect.width * ww + "px";
}


// rotate the plane
plane.rotation.x = -2;
plane.rotation.y = -0.8;

/* UNCOMMENT THIS LINE TO SHOW HOW NEAR PLANE CLIPPING AFFECTS OUR BOUNDING RECTANGLE VALUES */
//camera.fov = 150;

// render scene
render();

// show our bounding rectangle
displayBoundingRectangle();

function render() {
  renderer.render(scene, camera);

  requestAnimationFrame(render);
}
body {
  margin: 0;
}

#canvas {
  width: 100vw;
  height: 100vh;
}

#plane-bounding-rectangle {
  position: fixed;
  pointer-events: none;
  background: red;
  opacity: 0.2;
}
<script src="https://threejsfundamentals.org/threejs/resources/threejs/r115/build/three.min.js"></script>
<div id="canvas"></div>
<div id="plane-bounding-rectangle"></div>

Many thanks,


Solution

  • Based on my schema in the initial question, I've managed to solve my issue.

    I won't post the whole snippet here because it's kinda long and verbose (and it also feels a bit like a dirty hack), but this is the main idea:

    • Get the clipped and non clipped corners.

    • Use the non clipped corners coordinates and a really small vector going from that corner to the clipped one and recursively add it to our non clipped corner coordinate until we reached the near plane Z position: we found the intersection with the near plane.

    Let's say the top left corner of the plane is not clipped but the bottom left corner is clipped. To find the intersection between the camera near plane and the plane's left side, we'll do something like this:

    // find the intersection by adding a vector starting from a corner till we reach the near plane
    function getIntersection(refPoint, secondPoint) {
        // direction vector to add
        let vector = secondPoint.sub(refPoint);
        // copy our corner refpoint
        var intersection = refPoint.clone();
        // iterate till we reach near plane
        while(intersection.z > -1) {
            intersection.add(vector);
        }
    
        return intersection;
    }
    
    // get our top left corner projected coordinates
    let topLeftCorner = vertices[0].applyMatrix4(modelViewProjectionMatrix);
    
    // get a vector parallel to our left plane side toward the bottom left corner and project its coordinates as well
    let directionVector = vertices[0].clone().sub(new THREE.Vector3(0, -0.05, 0)).applyMatrix4(modelViewProjectionMatrix);
    
    // get the intersection with the near plane
    let bottomLeftIntersection = getIntersection(topLeftCorner, directionVector);
    

    I'm sure there would be a more analytical approach to solve this problem but this works, so I'm gonna stick with it for now.