Functions to calculate the visible width / height at a given z-depth from a perspective camera

This is exactly what I needed, thanks @looeee!

1 Like

Maybe I can use a similar technique to figure out which plane (at which depth) that sizing matches with physical pixels on the screen? Basically this question: How do we size something using screen pixels?

Maybe I’d have to do a binary search: divide the depth by two and see which mid-plane is closer to the size of the viewport, then proceed into that area and recurse. This seems like an “I don’t know the math so I’m just going to search for it” sort of method.

I think there’s also a real way to figure this out without a testing technique like binary search, but I’m not sure what that might be yet.

Here’s the binary search that (theoretically) finds the depth at which sizing matches pixels of the viewport:

function findScreenDepth( camera, renderer ) {
    const { near, far } = camera
    const { height:physicalViewHeight } = renderer.getDrawingBufferSize()
    console.log( window.innerHeight, physicalViewHeight )
    const threshold = 0.000001

    return _findScreenDepth( near, far )

    function _findScreenDepth( near, far ) {

        const midpoint = ( far - near ) / 2 + near
        const midpointHeight = visibleHeightAtZDepth( -midpoint, camera )

        if ( Math.abs( ( physicalViewHeight / midpointHeight ) - 1 ) <= threshold )
            return midpoint

        if ( physicalViewHeight < midpointHeight )
            return _findScreenDepth( near, midpoint )
        else if ( physicalViewHeight > midpointHeight )
            return _findScreenDepth( midpoint, far )
        else if ( midpointHeight == physicalViewHeight ) // almost never happens
            return midpoint
    }
}

But the results are not as accurate as I hoped. In the following codepen, you see the depth is logged to console.

(Open the demo on desktop, make sure the viewable area has bigger height than in the following embed, or else the red line does not show)

If you set the height value of the red box to match the height value of the window, you’ll notice the red box is slightly taller than the viewport, and you have to give it a size that is slightly smaller than the window for it to fit just right.

Maybe this is because of floating point error?

What would be the correct way to get this depth, if not with this binary search?

Nevermind, it works almost perfectly if I supply the exact floating point values programmatically, to the dimensions of the box:

The two lines are (basically) the same exact size. The teal line on the left is WebGL and the pink line on the right is a <div> absolutely positioned on top of the canvas (see element inspector).

Both lines have a height of 50px. Cool!

This works, but I think there might be a more precise way. I might be able to use this to map Three.js Object3Ds to DOM coordinate space…

Thanks @looeee, this is helping align some things with DOM.

For some reason though, things aren’t perfectly centered. In this example, you see the teal square behind the DIV element is not exactly lined up:

I believe that by default Three.js centers everything perfectly, and I thought DOM would too (with the CSS I’ve given it). Trying to find why this happens…

Sorry, I updated the last example: I removed the -5 X position from the teal square.

But still, in the previous example you can see the teal sticks out beyond the pink, but I’m expecting (hoping) for it to be precisely hidden behind the pink. Maybe there’s floating-point error?

If I translate the pink square up with -52% instead of -50% then I can manage to cover up the teal square:

I wonder why.

Are you looking at the embedded codepen (i.e. here on the discourse site) ?
Embedded pens do some weird stuff with resizing. If I open it in a new window the squares match exactly.

EDIT: actually I also had to remove the

body {
    perspective: 800px;
}

So actually the CSS was making the pink square inaccurate, not the other way around.

Ah! Interesting! It does match perfectly without CSS perspective.

I thought that no matter what value of perspective is applied, positioning on DOM content on the Z=0 plane should be exactly the same whether there’s perspective or not.

Could this be an aliasing difference introduced when there is perspective? I need to research the browser implementations. I find that no matter which value of CSS perspective I apply, the offset is always the same as long as any value of perspective exists, so it makes me think there’s something about aliasing at play.


This is what I wanted to figure out next anyways: matching the CSS perspective with the Three.js perspective so that I can transform both the mesh and the <div> in unison. Maybe this will help point to what the issue is.


EDIT: Dang, setting antialias: true for the renderer and leaving the CSS perspective in place didn’t work, there’s no difference:

@looeee You won’t believe it: the problem of the div shifting is because the <canvas> element is an inline element, and somehow that affects the div when perspective is applied. Making it display: block solves the problem! See:

Awesome. now I can move on! Thanks for your visibleHeightAtZDepth and visibleWidthAtZDepth functions. :smile:

2 Likes

Aha! After taking a look at this again, I realized that instead of using these functions and a binary search, matching the Three.js perspective to CSS3d perspective boils down to a short equation:

const perspective = 800

// this equation
const fov = 180 * ( 2 * Math.atan( innerHeight / 2 / perspective ) ) / Math.PI

// then align the Three.js perspective with the CSS3D perspective:
camera = new THREE.PerspectiveCamera( fov, window.innerWidth / window.innerHeight, 1, 10000 );
camera.position.set( 0, 0, perspective );
document.body.style.perspective = `${perspective}px`

Here’s a simple example. Note that the blue/pink color is due to the teal WebGL plane rendering on top of the pink DOM plane (zoom the camera to displace the WebGL rendered behind the DOM element):

At this point, the WebGL object and the DOM element can be moved in unison in the same 3D space. For example, let’s animate the positions and rotation of both (again if you zoom, you can see which one is the WebGL and which one is the DOM):

As an follow-on feature would also control the DOM position with the OrbitControls.

2 Likes

I need these function,thank you

@looeee I don’t understand why you subtract (or add) the camera’s z position from the depth before calculating the visible height. Is it not assumed that depth is given in view space? And in which space will it always be correct to transform to view space by subtracting z when z>depth and adding z when z<depth? :confused:

@trusktr What you did with the binary/bisection search was basically to invert the function. Looking at the stripped-down function (with depth assumed positive and from view space origin and in view space units):

height = f(depth) = 2 * tan( vFOV / 2 ) * depth

this is invertible just by moving things around a bit:

depth = g(height) = height / ( 2 * tan( vFOV / 2 ) )
1 Like

Looks like some of the above codepens I posted broke. I need to update them. Nowadays I make sure to use versioned URLs to make sure demos don’t break.

I updated all the above pens so they work now. I used specific versions in the code to make sure they rely on the proper versions, like this:

import * as THREE from 'https://unpkg.com/three@0.116.1/build/three.module.js'
import {OrbitControls} from 'https://unpkg.com/three@0.116.1/examples/jsm/controls/OrbitControls.js'

@looeee Do you mind updating your two codepens above? These are your pens updated to use specific versions:

1 Like

Hi
I am new to 3d graphics/rendering so apologies in advance
Do you think this is possible for orthographic camera

This fits good on Mobile device, both Android WebView and iOS webkit. You definitely knows the logic behind it. Good job, man.

1 Like

With an OrthographicCamera, objects don’t get smaller when they go further away from the camera so this trick doesn’t work.

What you would need to do is make sure the ortho camera box dimensions are the size of the object, or scale the object to fit the size of the camera box. This is easier than the above with perspective camera, you just need a multiplier.

For example, if your ortho cam width and height are both 10, and the object width/height is 5, then you need to scale the object by 2 so it fits on the screen (or reduce the camera width/height to 5). Simple as that.

In case it helps anyone, here’s how to fit an object (most likely a flat plane) on the screen so that it “fits” depending on aspect ratio. For example, if we have an object taller than it is wide we want the Y dimension (the tall dimension) to fit within the view, but if the object is wider than it is tall we want the X dimension (the width dimension) to fit within view.

The following distanceToFitObjectToView function will tell you at what distance to place the object so that it fits within view regardless which dimension is bigger:

(The code has TypeScript type annotations, but you can remove them, f.e. the : number stuff)

/**
 * Convert vertical field of view to horizontal field of view, given an aspect
 * ratio. See https://arstechnica.com/civis/viewtopic.php?f=6&t=37447
 *
 * @param vfov - The vertical field of view.
 * @param aspect - The aspect ratio, which is generally width/height of the viewport.
 * @returns - The horizontal field of view.
 */
function vfovToHfov(vfov: number, aspect: number): number {
  const {tan, atan} = Math
  return atan(aspect * tan(vfov / 2)) * 2
}

/**
 * Get the distance from the camera to fit an object in view by either its
 * horizontal or its vertical dimension.
 *
 * @param size - This should be the width or height of the object to fit.
 * @param fov - If `size` is the object's width, `fov` should be the horizontal
 * field of view of the view camera. If `size` is the object's height, then
 * `fov` should be the view camera's vertical field of view.
 * @returns - The distance from the camera so that the object will fit from
 * edge to edge of the viewport.
 */
function _distanceToFitObjectInView(size: number, fov: number): number {
  const {tan} = Math
  return size / (2 * tan(fov / 2))
}

function distanceToFitObjectToView(
  cameraAspect: number,
  cameraVFov: number,
  objWidth: number,
  objHeight: number
): number {
  const objAspect = objWidth / objHeight
  const cameraHFov = vfovToHfov(cameraVFov, cameraAspect)

  let distance: number = 0

  if (objAspect > cameraAspect) {
    distance = _distanceToFitObjectInView(objHeight, cameraVFov)
  } else if (objAspect <= cameraAspect) {
    distance = _distanceToFitObjectInView(objWidth, cameraHFov)
  }

  return distance
}
1 Like

Oops! I forgot to include the vfovToHfov function. Updated the above post.